rename
This commit is contained in:
parent
6206dbde62
commit
c78241e204
18 changed files with 662 additions and 29 deletions
|
@ -58,6 +58,7 @@ dependencies {
|
|||
implementation 'com.github.bumptech.glide:glide:4.9.0'
|
||||
annotationProcessor 'androidx.annotation:annotation:1.1.0'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'
|
||||
implementation 'com.google.guava:guava:28.0-android'
|
||||
implementation 'com.karumi:dexter:5.0.0'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'com.android.support.test:runner:1.0.2'
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
package deckers.thibault.aves;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
||||
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
|
||||
import deckers.thibault.aves.channelhandlers.ImageDecodeHandler;
|
||||
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
|
||||
import deckers.thibault.aves.channelhandlers.MetadataHandler;
|
||||
import deckers.thibault.aves.utils.Constants;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import io.flutter.app.FlutterActivity;
|
||||
import io.flutter.plugin.common.EventChannel;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
@ -26,5 +31,26 @@ public class MainActivity extends FlutterActivity {
|
|||
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
||||
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (requestCode == Constants.SD_CARD_PERMISSION_REQUEST_CODE && resultCode == RESULT_OK) {
|
||||
Uri sdCardDocumentUri = data.getData();
|
||||
if (sdCardDocumentUri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Env.setSdCardDocumentUri(this, sdCardDocumentUri.toString());
|
||||
|
||||
// save access permissions across reboots
|
||||
final int takeFlags = data.getFlags()
|
||||
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
getContentResolver().takePersistableUriPermission(sdCardDocumentUri, takeFlags);
|
||||
|
||||
// resume pending action
|
||||
PermissionManager.onPermissionGranted(requestCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,8 @@ import android.app.Activity;
|
|||
import android.app.AlertDialog;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
@ -19,6 +21,8 @@ import com.karumi.dexter.listener.single.PermissionListener;
|
|||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.model.provider.ImageProvider;
|
||||
import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
|
@ -41,30 +45,68 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
|||
case "getImageEntries":
|
||||
getPermissionResult(result, activity);
|
||||
break;
|
||||
case "getImageBytes": {
|
||||
Map map = call.argument("entry");
|
||||
Integer width = call.argument("width");
|
||||
Integer height = call.argument("height");
|
||||
if (map == null) {
|
||||
result.error("getImageBytes-args", "failed to get image bytes because 'entry' is null", null);
|
||||
return;
|
||||
}
|
||||
ImageEntry entry = new ImageEntry(map);
|
||||
imageDecodeTaskManager.fetch(result, entry, width, height);
|
||||
case "getImageBytes":
|
||||
getImageBytes(call, result);
|
||||
break;
|
||||
}
|
||||
case "cancelGetImageBytes": {
|
||||
String uri = call.argument("uri");
|
||||
imageDecodeTaskManager.cancel(uri);
|
||||
result.success(null);
|
||||
case "cancelGetImageBytes":
|
||||
cancelGetImageBytes(call, result);
|
||||
break;
|
||||
case "rename":
|
||||
rename(call, result);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void getImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
Map map = call.argument("entry");
|
||||
Integer width = call.argument("width");
|
||||
Integer height = call.argument("height");
|
||||
if (map == null || width == null || height == null) {
|
||||
result.error("getImageBytes-args", "failed because of missing arguments", null);
|
||||
return;
|
||||
}
|
||||
ImageEntry entry = new ImageEntry(map);
|
||||
imageDecodeTaskManager.fetch(result, entry, width, height);
|
||||
}
|
||||
|
||||
private void cancelGetImageBytes(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
String uri = call.argument("uri");
|
||||
imageDecodeTaskManager.cancel(uri);
|
||||
result.success(null);
|
||||
}
|
||||
|
||||
private void rename(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
Map map = call.argument("entry");
|
||||
String newName = call.argument("newName");
|
||||
if (map == null || newName == null) {
|
||||
result.error("rename-args", "failed because of missing arguments", null);
|
||||
return;
|
||||
}
|
||||
Uri uri = Uri.parse((String) map.get("uri"));
|
||||
String path = (String) map.get("path");
|
||||
String mimeType = (String) map.get("mimeType");
|
||||
|
||||
ImageProvider provider = ImageProviderFactory.getProvider(uri);
|
||||
if (provider == null) {
|
||||
result.error("rename-provider", "failed to find provider for uri=" + uri, null);
|
||||
return;
|
||||
}
|
||||
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> newFields) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void getPermissionResult(final MethodChannel.Result result, final Activity activity) {
|
||||
Dexter.withActivity(activity)
|
||||
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
package deckers.thibault.aves.model.provider;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ContentUris;
|
||||
import android.database.Cursor;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
||||
public abstract class ImageProvider {
|
||||
private static final String LOG_TAG = Utils.createLogTag(ImageProvider.class);
|
||||
|
||||
public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final RenameCallback callback) {
|
||||
if (oldPath == null) {
|
||||
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri);
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
File oldFile = new File(oldPath);
|
||||
File newFile = new File(oldFile.getParent(), newFilename);
|
||||
if (oldFile.equals(newFile)) {
|
||||
Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath);
|
||||
callback.onSuccess(newFields);
|
||||
return;
|
||||
}
|
||||
|
||||
// Before KitKat, we do whatever we want on the SD card.
|
||||
// From KitKat, we need access permission from the Document Provider, at the file level.
|
||||
// From Lollipop, we can request the permission at the SD card root level.
|
||||
boolean renamed;
|
||||
if (!Env.isOnSdCard(activity, oldPath)) {
|
||||
// rename with File
|
||||
renamed = oldFile.renameTo(newFile);
|
||||
} else {
|
||||
// rename with DocumentFile
|
||||
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
if (sdCardTreeUri == null) {
|
||||
Runnable runnable = () -> rename(activity, oldPath, oldUri, mimeType, newFilename, callback);
|
||||
PermissionManager.showSdCardAccessDialog(activity, runnable);
|
||||
return;
|
||||
}
|
||||
renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename);
|
||||
}
|
||||
|
||||
if (!renamed) {
|
||||
Log.w(LOG_TAG, "failed to rename entry at path=" + oldPath);
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null);
|
||||
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, uri) -> {
|
||||
Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", uri=" + uri);
|
||||
if (uri != null) {
|
||||
// we retrieve updated fields as the renamed file became a new entry in the Media Store
|
||||
String[] projection = {MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE};
|
||||
try {
|
||||
Cursor cursor = activity.getContentResolver().query(uri, projection, null, null, null);
|
||||
if (cursor != null) {
|
||||
if (cursor.moveToNext()) {
|
||||
long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
|
||||
Uri itemUri = ContentUris.withAppendedId(MediaStoreImageProvider.FILES_URI, contentId);
|
||||
newFields.put("uri", itemUri.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();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(LOG_TAG, "failed to update MediaStore after renaming entry at path=" + oldPath, e);
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback.onSuccess(newFields);
|
||||
});
|
||||
}
|
||||
|
||||
public interface RenameCallback {
|
||||
void onSuccess(Map<String, Object> newFields);
|
||||
|
||||
void onFailure();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package deckers.thibault.aves.model.provider;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.net.Uri;
|
||||
import android.provider.MediaStore;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class ImageProviderFactory {
|
||||
public static ImageProvider getProvider(@NonNull Uri uri) {
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme != null) {
|
||||
switch (scheme) {
|
||||
case ContentResolver.SCHEME_CONTENT: // content://
|
||||
String authority = uri.getAuthority();
|
||||
if (authority != null) {
|
||||
switch (authority) {
|
||||
case MediaStore.AUTHORITY:
|
||||
return new MediaStoreImageProvider();
|
||||
// case Constants.DOWNLOADS_AUTHORITY:
|
||||
// return new DownloadImageProvider();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
// case ContentResolver.SCHEME_FILE: // file://
|
||||
// return new FileImageProvider();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -13,10 +13,10 @@ import java.util.stream.Stream;
|
|||
import deckers.thibault.aves.model.ImageEntry;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
||||
public class MediaStoreImageProvider {
|
||||
public class MediaStoreImageProvider extends ImageProvider {
|
||||
private static final String LOG_TAG = Utils.createLogTag(MediaStoreImageProvider.class);
|
||||
|
||||
private Uri FILES_URI = MediaStore.Files.getContentUri("external");
|
||||
public static Uri FILES_URI = MediaStore.Files.getContentUri("external");
|
||||
|
||||
private static final String[] PROJECTION = {
|
||||
// image & video
|
||||
|
|
|
@ -6,6 +6,8 @@ import java.util.HashMap;
|
|||
import java.util.Map;
|
||||
|
||||
public class Constants {
|
||||
public static final int SD_CARD_PERMISSION_REQUEST_CODE = 1;
|
||||
|
||||
// mime types
|
||||
|
||||
public static final String MIME_VIDEO = "video";
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Environment;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class Env {
|
||||
private static String[] mStorageVolumes;
|
||||
private static String mExternalStorage;
|
||||
// SD card path as a content URI from the Documents Provider
|
||||
// e.g. content://com.android.externalstorage.documents/tree/12A9-8B42%3A
|
||||
private static String mSdCardDocumentUri;
|
||||
|
||||
private static final String PREF_SD_CARD_DOCUMENT_URI = "sd_card_document_uri";
|
||||
|
||||
public static void setSdCardDocumentUri(final Activity activity, String SdCardDocumentUri) {
|
||||
mSdCardDocumentUri = SdCardDocumentUri;
|
||||
SharedPreferences.Editor preferences = activity.getPreferences(Context.MODE_PRIVATE).edit();
|
||||
preferences.putString(PREF_SD_CARD_DOCUMENT_URI, mSdCardDocumentUri);
|
||||
preferences.apply();
|
||||
}
|
||||
|
||||
public static String getSdCardDocumentUri(final Activity activity) {
|
||||
if (mSdCardDocumentUri == null) {
|
||||
SharedPreferences preferences = activity.getPreferences(Context.MODE_PRIVATE);
|
||||
mSdCardDocumentUri = preferences.getString(PREF_SD_CARD_DOCUMENT_URI, null);
|
||||
}
|
||||
return mSdCardDocumentUri;
|
||||
}
|
||||
|
||||
public static String[] getStorageVolumes(final Activity activity) {
|
||||
if (mStorageVolumes == null) {
|
||||
mStorageVolumes = StorageUtils.getStorageVolumes(activity);
|
||||
}
|
||||
return mStorageVolumes;
|
||||
}
|
||||
|
||||
private static String getExternalStorage() {
|
||||
if (mExternalStorage == null) {
|
||||
mExternalStorage = Environment.getExternalStorageDirectory().toString();
|
||||
}
|
||||
return mExternalStorage;
|
||||
}
|
||||
|
||||
public static boolean isOnSdCard(final Activity activity, @NonNull String path) {
|
||||
return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class PathComponents {
|
||||
private String storage;
|
||||
private String folder;
|
||||
private String filename;
|
||||
|
||||
public PathComponents(@NonNull String path, @NonNull String[] storageVolumes) {
|
||||
for (int i = 0; i < storageVolumes.length && storage == null; i++) {
|
||||
if (path.startsWith(storageVolumes[i])) {
|
||||
storage = storageVolumes[i];
|
||||
}
|
||||
}
|
||||
|
||||
int lastSeparatorIndex = path.lastIndexOf(File.separator) + 1;
|
||||
if (lastSeparatorIndex > storage.length()) {
|
||||
filename = path.substring(lastSeparatorIndex);
|
||||
folder = path.substring(storage.length(), lastSeparatorIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public String getStorage() {
|
||||
return storage;
|
||||
}
|
||||
|
||||
String getFolder() {
|
||||
return folder;
|
||||
}
|
||||
|
||||
String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.UriPermission;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class PermissionManager {
|
||||
private static final String LOG_TAG = Utils.createLogTag(PermissionManager.class);
|
||||
|
||||
// permission request code to pending runnable
|
||||
private static ConcurrentHashMap<Integer, Runnable> pendingPermissionMap = new ConcurrentHashMap<>();
|
||||
|
||||
// check access permission to SD card directory & return its content URI if available
|
||||
public static Uri getSdCardTreeUri(Activity activity) {
|
||||
final String sdCardDocumentUri = Env.getSdCardDocumentUri(activity);
|
||||
Optional<UriPermission> uriPermissionOptional = activity.getContentResolver().getPersistedUriPermissions().stream()
|
||||
.filter(uriPermission -> uriPermission.getUri().toString().equals(sdCardDocumentUri))
|
||||
.findFirst();
|
||||
return uriPermissionOptional.map(UriPermission::getUri).orElse(null);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public static void showSdCardAccessDialog(final Activity activity, final Runnable pendingRunnable) {
|
||||
new AlertDialog.Builder(activity)
|
||||
.setTitle("SD Card Access")
|
||||
.setMessage("Please select the root directory of the SD card in the next screen, so that this app has permission to access it and complete your request.")
|
||||
.setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
|
||||
Log.i(LOG_TAG, "request user to select and grant access permission to SD card");
|
||||
pendingPermissionMap.put(Constants.SD_CARD_PERMISSION_REQUEST_CODE, pendingRunnable);
|
||||
ActivityCompat.startActivityForResult(activity,
|
||||
new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
|
||||
Constants.SD_CARD_PERMISSION_REQUEST_CODE, null);
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
public static void onPermissionGranted(int requestCode) {
|
||||
Runnable runnable = pendingPermissionMap.remove(requestCode);
|
||||
if (runnable != null) {
|
||||
runnable.run();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.collect.Lists;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public class StorageUtils {
|
||||
private static final String LOG_TAG = Utils.createLogTag(StorageUtils.class);
|
||||
|
||||
/**
|
||||
* Returns all available SD-Cards in the system (include emulated)
|
||||
* <p/>
|
||||
* Warning: Hack! Based on Android source code of version 4.3 (API 18)
|
||||
* Because there is no standard way to get it.
|
||||
* Edited by hendrawd
|
||||
*
|
||||
* @return paths to all available SD-Cards in the system (include emulated)
|
||||
*/
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
public static String[] getStorageVolumes(Context context) {
|
||||
// Final set of paths
|
||||
final Set<String> rv = new HashSet<>();
|
||||
|
||||
// Primary emulated SD-CARD
|
||||
final String rawEmulatedStorageTarget = System.getenv("EMULATED_STORAGE_TARGET");
|
||||
|
||||
if (TextUtils.isEmpty(rawEmulatedStorageTarget)) {
|
||||
// fix of empty raw emulated storage on marshmallow
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
File[] files = context.getExternalFilesDirs(null);
|
||||
for (File file : files) {
|
||||
String applicationSpecificAbsolutePath = file.getAbsolutePath();
|
||||
String emulatedRootPath = applicationSpecificAbsolutePath.substring(0, applicationSpecificAbsolutePath.indexOf("Android/data"));
|
||||
rv.add(emulatedRootPath);
|
||||
}
|
||||
} else {
|
||||
// Primary physical SD-CARD (not emulated)
|
||||
final String rawExternalStorage = System.getenv("EXTERNAL_STORAGE");
|
||||
|
||||
// Device has physical external storage; use plain paths.
|
||||
if (TextUtils.isEmpty(rawExternalStorage)) {
|
||||
// EXTERNAL_STORAGE undefined; falling back to default.
|
||||
rv.addAll(Arrays.asList(getPhysicalPaths()));
|
||||
} else {
|
||||
rv.add(rawExternalStorage);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||
final String rawUserId;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
rawUserId = "";
|
||||
} else {
|
||||
final String path = Environment.getExternalStorageDirectory().getAbsolutePath();
|
||||
final String[] folders = path.split(File.separator);
|
||||
final String lastFolder = folders[folders.length - 1];
|
||||
boolean isDigit = TextUtils.isDigitsOnly(lastFolder);
|
||||
rawUserId = isDigit ? lastFolder : "";
|
||||
}
|
||||
// /storage/emulated/0[1,2,...]
|
||||
if (TextUtils.isEmpty(rawUserId)) {
|
||||
rv.add(rawEmulatedStorageTarget);
|
||||
} else {
|
||||
rv.add(rawEmulatedStorageTarget + File.separator + rawUserId);
|
||||
}
|
||||
}
|
||||
|
||||
// All Secondary SD-CARDs (all exclude primary) separated by ":"
|
||||
final String rawSecondaryStoragesStr = System.getenv("SECONDARY_STORAGE");
|
||||
|
||||
// Add all secondary storages
|
||||
if (!TextUtils.isEmpty(rawSecondaryStoragesStr)) {
|
||||
// All Secondary SD-CARDs split into array
|
||||
final String[] rawSecondaryStorages = rawSecondaryStoragesStr.split(File.pathSeparator);
|
||||
Collections.addAll(rv, rawSecondaryStorages);
|
||||
}
|
||||
|
||||
String[] paths = rv.toArray(new String[0]);
|
||||
for (int i = 0; i < paths.length; i++) {
|
||||
String path = paths[i];
|
||||
if (path.endsWith(File.separator)) {
|
||||
paths[i] = path.substring(0, path.length() - 1);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return physicalPaths based on phone model
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
private static String[] getPhysicalPaths() {
|
||||
return new String[]{
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
};
|
||||
}
|
||||
|
||||
private static Optional<DocumentFile> getSdCardDocumentFile(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path) {
|
||||
if (sdCardTreeUri == null || storageVolumes == null || path == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
PathComponents pathComponents = new PathComponents(path, storageVolumes);
|
||||
ArrayList<String> pathSegments = Lists.newArrayList(Splitter.on(File.separatorChar)
|
||||
.trimResults().omitEmptyStrings().split(pathComponents.getFolder()));
|
||||
pathSegments.add(pathComponents.getFilename());
|
||||
Iterator<String> pathIterator = pathSegments.iterator();
|
||||
|
||||
// follow the entry path down the document tree
|
||||
boolean found = true;
|
||||
DocumentFile documentFile = DocumentFile.fromTreeUri(context, sdCardTreeUri);
|
||||
while (pathIterator.hasNext() && found) {
|
||||
String segment = pathIterator.next();
|
||||
found = false;
|
||||
if (documentFile != null) {
|
||||
DocumentFile[] children = documentFile.listFiles();
|
||||
for (int i = children.length - 1; i >= 0 && !found; i--) {
|
||||
DocumentFile child = children[i];
|
||||
if (segment.equals(child.getName())) {
|
||||
found = true;
|
||||
documentFile = child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found && documentFile != null ? Optional.of(documentFile) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the specified file on SD card
|
||||
* Note that it does not update related content providers such as the Media Store.
|
||||
*/
|
||||
public static boolean deleteFromSdCard(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path) {
|
||||
Optional<DocumentFile> documentFile = getSdCardDocumentFile(context, sdCardTreeUri, storageVolumes, path);
|
||||
boolean success = documentFile.isPresent() && documentFile.get().delete();
|
||||
Log.d(LOG_TAG, "deleteFromSdCard success=" + success + " for sdCardTreeUri=" + sdCardTreeUri + ", path=" + path);
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the specified file on SD card
|
||||
* Note that it does not update related content providers such as the Media Store.
|
||||
*/
|
||||
public static boolean renameOnSdCard(Context context, Uri sdCardTreeUri, String[] storageVolumes, String path, String newFilename) {
|
||||
Log.d(LOG_TAG, "renameOnSdCard with path=" + path + ", newFilename=" + newFilename);
|
||||
Optional<DocumentFile> documentFile = getSdCardDocumentFile(context, sdCardTreeUri, storageVolumes, path);
|
||||
return documentFile.isPresent() && documentFile.get().renameTo(newFilename);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <!-- API28+, draws next to the notch in fullscreen -->
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowTranslucentNavigation">@bool/translucentNavBar</item> <!-- API19+, tinted background & request the SYSTEM_UI_FLAG_LAYOUT_STABLE and SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN flags -->
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
@ -41,4 +41,18 @@ class ImageDecodeService {
|
|||
debugPrint('cancelGetImageBytes failed with exception=${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map> rename(ImageEntry entry, String newName) async {
|
||||
try {
|
||||
// return map with: 'contentId' 'path' 'title' 'uri' (all optional)
|
||||
final result = await platform.invokeMethod('rename', <String, dynamic>{
|
||||
'entry': entry.toMap(),
|
||||
'newName': newName,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('rename failed with exception=${e.message}');
|
||||
}
|
||||
return Map();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:aves/model/image_decode_service.dart';
|
||||
import 'package:aves/model/image_metadata.dart';
|
||||
import 'package:aves/model/metadata_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geocoder/geocoder.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
import 'mime_types.dart';
|
||||
|
||||
class ImageEntry with ChangeNotifier {
|
||||
final String uri;
|
||||
final String path;
|
||||
final int contentId;
|
||||
String uri;
|
||||
String path;
|
||||
int contentId;
|
||||
final String mimeType;
|
||||
final int width;
|
||||
final int height;
|
||||
final int orientationDegrees;
|
||||
final int sizeBytes;
|
||||
final String title;
|
||||
String title;
|
||||
final int dateModifiedSecs;
|
||||
final int sourceDateTakenMillis;
|
||||
final String bucketDisplayName;
|
||||
|
@ -82,6 +84,8 @@ class ImageEntry with ChangeNotifier {
|
|||
return 'ImageEntry{uri=$uri, path=$path}';
|
||||
}
|
||||
|
||||
String get filename => basenameWithoutExtension(path);
|
||||
|
||||
bool get isGif => mimeType == MimeTypes.MIME_GIF;
|
||||
|
||||
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
|
||||
|
@ -183,4 +187,22 @@ class ImageEntry with ChangeNotifier {
|
|||
if (isLocated && addressDetails.addressLine.toLowerCase().contains(query)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> rename(String newName) async {
|
||||
if (newName == filename) return true;
|
||||
|
||||
final newFields = await ImageDecodeService.rename(this, '$newName${extension(this.path)}');
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final uri = newFields['uri'];
|
||||
if (uri != null) this.uri = uri;
|
||||
final path = newFields['path'];
|
||||
if (path != null) this.path = path;
|
||||
final contentId = newFields['contentId'];
|
||||
if (contentId != null) this.contentId = contentId;
|
||||
final title = newFields['title'];
|
||||
if (title != null) this.title = title;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import 'package:photo_view/photo_view.dart';
|
|||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:screen/screen.dart';
|
||||
|
||||
class FullscreenPage extends StatefulWidget {
|
||||
class FullscreenPage extends StatelessWidget {
|
||||
final List<ImageEntry> entries;
|
||||
final String initialUri;
|
||||
|
||||
|
@ -25,10 +25,39 @@ class FullscreenPage extends StatefulWidget {
|
|||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
FullscreenPageState createState() => FullscreenPageState();
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: () {
|
||||
Screen.keepOn(false);
|
||||
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
|
||||
return Future.value(true);
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: FullscreenBody(
|
||||
entries: entries,
|
||||
initialUri: initialUri,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FullscreenPageState extends State<FullscreenPage> with SingleTickerProviderStateMixin {
|
||||
class FullscreenBody extends StatefulWidget {
|
||||
final List<ImageEntry> entries;
|
||||
final String initialUri;
|
||||
|
||||
const FullscreenBody({
|
||||
Key key,
|
||||
this.entries,
|
||||
this.initialUri,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
FullscreenBodyState createState() => FullscreenBodyState();
|
||||
}
|
||||
|
||||
class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProviderStateMixin {
|
||||
bool _isInitialScale = true;
|
||||
int _currentHorizontalPage, _currentVerticalPage = 0;
|
||||
PageController _horizontalPager, _verticalPager;
|
||||
|
@ -193,6 +222,35 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
|||
}
|
||||
}
|
||||
|
||||
showRenameDialog(ImageEntry entry) async {
|
||||
final currentName = entry.title;
|
||||
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'),
|
||||
),
|
||||
FlatButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text('APPLY'),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
final success = await entry.rename(newName);
|
||||
final snackBar = SnackBar(content: Text(success ? 'Done!' : 'Failed'));
|
||||
Scaffold.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
||||
switch (action) {
|
||||
case FullscreenAction.edit:
|
||||
|
@ -201,6 +259,9 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
|||
case FullscreenAction.info:
|
||||
goToVerticalPage(1);
|
||||
break;
|
||||
case FullscreenAction.rename:
|
||||
showRenameDialog(entry);
|
||||
break;
|
||||
case FullscreenAction.setAs:
|
||||
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
||||
break;
|
||||
|
@ -214,7 +275,7 @@ class FullscreenPageState extends State<FullscreenPage> with SingleTickerProvide
|
|||
}
|
||||
}
|
||||
|
||||
enum FullscreenAction { edit, info, setAs, share, showOnMap }
|
||||
enum FullscreenAction { edit, info, rename, setAs, share, showOnMap }
|
||||
|
||||
class ImagePage extends StatefulWidget {
|
||||
final List<ImageEntry> entries;
|
||||
|
|
|
@ -17,6 +17,8 @@ class MetadataSection extends StatefulWidget {
|
|||
class MetadataSectionState extends State<MetadataSection> {
|
||||
Future<Map> _metadataLoader;
|
||||
|
||||
static const int maxValueLength = 140;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -56,7 +58,10 @@ class MetadataSectionState extends State<MetadataSection> {
|
|||
padding: EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Text(directoryName, style: TextStyle(fontSize: 18)),
|
||||
),
|
||||
...tagKeys.map((tagKey) => InfoRow(tagKey, directory[tagKey])),
|
||||
...tagKeys.map((tagKey) {
|
||||
final value = directory[tagKey] as String;
|
||||
return InfoRow(tagKey, value.length > maxValueLength ? '${value.substring(0, maxValueLength)}…' : value);
|
||||
}),
|
||||
SizedBox(height: 16),
|
||||
];
|
||||
},
|
||||
|
|
|
@ -56,6 +56,10 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
value: FullscreenAction.edit,
|
||||
child: Text("Edit"),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.rename,
|
||||
child: Text("Rename"),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.setAs,
|
||||
child: Text("Set as"),
|
||||
|
|
Loading…
Reference in a new issue