This commit is contained in:
Thibault Deckers 2019-08-15 13:38:56 +09:00
parent 6206dbde62
commit c78241e204
18 changed files with 662 additions and 29 deletions

View file

@ -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'

View file

@ -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);
}
}
}

View file

@ -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)

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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";

View file

@ -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());
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}
}

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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),
];
},

View file

@ -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"),