fullscreen: added rotate action
This commit is contained in:
parent
5571f9f236
commit
0c8318444b
14 changed files with 515 additions and 148 deletions
|
@ -5,7 +5,7 @@ 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.ImageFileHandler;
|
||||
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
|
||||
import deckers.thibault.aves.channelhandlers.MetadataHandler;
|
||||
import deckers.thibault.aves.utils.Constants;
|
||||
|
@ -27,7 +27,7 @@ public class MainActivity extends FlutterActivity {
|
|||
|
||||
FlutterView messenger = getFlutterView();
|
||||
new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
|
||||
new MethodChannel(messenger, ImageDecodeHandler.CHANNEL).setMethodCallHandler(new ImageDecodeHandler(this, mediaStoreStreamHandler));
|
||||
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
|
||||
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
|
||||
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
|
||||
}
|
||||
|
|
|
@ -26,14 +26,14 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory;
|
|||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
||||
public class ImageFileHandler implements MethodChannel.MethodCallHandler {
|
||||
public static final String CHANNEL = "deckers.thibault/aves/image";
|
||||
|
||||
private Activity activity;
|
||||
private ImageDecodeTaskManager imageDecodeTaskManager;
|
||||
private MediaStoreStreamHandler mediaStoreStreamHandler;
|
||||
|
||||
public ImageDecodeHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
|
||||
public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
|
||||
this.activity = activity;
|
||||
imageDecodeTaskManager = new ImageDecodeTaskManager(activity);
|
||||
this.mediaStoreStreamHandler = mediaStoreStreamHandler;
|
||||
|
@ -54,6 +54,9 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
|||
case "rename":
|
||||
rename(call, result);
|
||||
break;
|
||||
case "rotate":
|
||||
rotate(call, result);
|
||||
break;
|
||||
default:
|
||||
result.notImplemented();
|
||||
break;
|
||||
|
@ -94,7 +97,7 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
|||
result.error("rename-provider", "failed to find provider for uri=" + uri, null);
|
||||
return;
|
||||
}
|
||||
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.RenameCallback() {
|
||||
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
public void onSuccess(Map<String, Object> newFields) {
|
||||
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
|
||||
|
@ -107,6 +110,35 @@ public class ImageDecodeHandler implements MethodChannel.MethodCallHandler {
|
|||
});
|
||||
}
|
||||
|
||||
private void rotate(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
|
||||
Map map = call.argument("entry");
|
||||
Boolean clockwise = call.argument("clockwise");
|
||||
if (map == null || clockwise == null) {
|
||||
result.error("rotate-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("rotate-provider", "failed to find provider for uri=" + uri, null);
|
||||
return;
|
||||
}
|
||||
provider.rotate(activity, path, uri, mimeType, clockwise, new ImageProvider.ImageOpCallback() {
|
||||
@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("rotate-failure", "failed to rotate", null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void getPermissionResult(final MethodChannel.Result result, final Activity activity) {
|
||||
Dexter.withActivity(activity)
|
||||
.withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
@ -30,7 +30,7 @@ import java.util.Map;
|
|||
import java.util.TimeZone;
|
||||
|
||||
import deckers.thibault.aves.utils.Constants;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
|
@ -200,7 +200,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
|
|||
retriever.release();
|
||||
|
||||
if (dateString != null) {
|
||||
long dateMillis = Utils.parseVideoMetadataDate(dateString);
|
||||
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
|
||||
// some videos have an invalid default date (19040101T000000.000Z) that is before Epoch time
|
||||
if (dateMillis > 0) {
|
||||
metadataMap.put("dateMillis", dateMillis);
|
||||
|
|
|
@ -2,17 +2,29 @@ package deckers.thibault.aves.model.provider;
|
|||
|
||||
import android.app.Activity;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Matrix;
|
||||
import android.media.ExifInterface;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import deckers.thibault.aves.utils.Constants;
|
||||
import deckers.thibault.aves.utils.Env;
|
||||
import deckers.thibault.aves.utils.MetadataHelper;
|
||||
import deckers.thibault.aves.utils.PermissionManager;
|
||||
import deckers.thibault.aves.utils.StorageUtils;
|
||||
import deckers.thibault.aves.utils.Utils;
|
||||
|
@ -20,7 +32,7 @@ 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) {
|
||||
public void rename(final Activity activity, final String oldPath, final Uri oldUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
|
||||
if (oldPath == null) {
|
||||
Log.w(LOG_TAG, "entry does not have a path, uri=" + oldUri);
|
||||
callback.onFailure();
|
||||
|
@ -40,10 +52,7 @@ public abstract class ImageProvider {
|
|||
// 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 {
|
||||
if (Env.isOnSdCard(activity, oldPath)) {
|
||||
// rename with DocumentFile
|
||||
Uri sdCardTreeUri = PermissionManager.getSdCardTreeUri(activity);
|
||||
if (sdCardTreeUri == null) {
|
||||
|
@ -52,6 +61,9 @@ public abstract class ImageProvider {
|
|||
return;
|
||||
}
|
||||
renamed = StorageUtils.renameOnSdCard(activity, sdCardTreeUri, Env.getStorageVolumes(activity), oldPath, newFilename);
|
||||
} else {
|
||||
// rename with File
|
||||
renamed = oldFile.renameTo(newFile);
|
||||
}
|
||||
|
||||
if (!renamed) {
|
||||
|
@ -89,7 +101,148 @@ public abstract class ImageProvider {
|
|||
});
|
||||
}
|
||||
|
||||
public interface RenameCallback {
|
||||
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
|
||||
switch (mimeType) {
|
||||
case Constants.MIME_JPEG:
|
||||
rotateJpeg(activity, path, uri, mimeType, clockwise, callback);
|
||||
break;
|
||||
case Constants.MIME_PNG:
|
||||
rotatePng(activity, path, uri, mimeType, clockwise, callback);
|
||||
break;
|
||||
default:
|
||||
callback.onFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private void rotateJpeg(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) {
|
||||
String editablePath = path;
|
||||
boolean onSdCard = Env.isOnSdCard(activity, path);
|
||||
if (onSdCard) {
|
||||
if (PermissionManager.getSdCardTreeUri(activity) == null) {
|
||||
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
|
||||
PermissionManager.showSdCardAccessDialog(activity, runnable);
|
||||
return;
|
||||
}
|
||||
// copy original file to a temporary file for editing
|
||||
editablePath = StorageUtils.copyFileToTemp(path);
|
||||
}
|
||||
|
||||
if (editablePath == null) {
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
boolean rotated = false;
|
||||
int newOrientationCode = 0;
|
||||
try {
|
||||
ExifInterface exif = new ExifInterface(editablePath);
|
||||
switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||
case ExifInterface.ORIENTATION_ROTATE_90:
|
||||
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_180 : ExifInterface.ORIENTATION_NORMAL;
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_180:
|
||||
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_270 : ExifInterface.ORIENTATION_ROTATE_90;
|
||||
break;
|
||||
case ExifInterface.ORIENTATION_ROTATE_270:
|
||||
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_NORMAL : ExifInterface.ORIENTATION_ROTATE_180;
|
||||
break;
|
||||
default:
|
||||
newOrientationCode = clockwise ? ExifInterface.ORIENTATION_ROTATE_90 : ExifInterface.ORIENTATION_ROTATE_270;
|
||||
break;
|
||||
}
|
||||
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(newOrientationCode));
|
||||
exif.saveAttributes();
|
||||
|
||||
// if the image is on the SD card, copy the edited temporary file to the original DocumentFile
|
||||
rotated = !onSdCard || StorageUtils.writeToDocumentFile(activity, editablePath, uri);
|
||||
} catch (IOException e) {
|
||||
Log.w(LOG_TAG, "failed to edit EXIF to rotate image at path=" + path, e);
|
||||
}
|
||||
if (!rotated) {
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
// update fields in media store
|
||||
ContentValues values = new ContentValues();
|
||||
int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode);
|
||||
values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
|
||||
if (activity.getContentResolver().update(uri, values, null, null) > 0) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> {
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
newFields.put("orientationDegrees", orientationDegrees);
|
||||
callback.onSuccess(newFields);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void rotatePng(Activity activity, final String path, final Uri uri, final String mimeType, boolean clockwise, final ImageOpCallback callback) {
|
||||
if (path == null) {
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
boolean onSdCard = Env.isOnSdCard(activity, path);
|
||||
if (onSdCard && PermissionManager.getSdCardTreeUri(activity) == null) {
|
||||
Runnable runnable = () -> rotate(activity, path, uri, mimeType, clockwise, callback);
|
||||
PermissionManager.showSdCardAccessDialog(activity, runnable);
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap originalImage = BitmapFactory.decodeFile(path);
|
||||
Matrix matrix = new Matrix();
|
||||
int originalWidth = originalImage.getWidth();
|
||||
int originalHeight = originalImage.getHeight();
|
||||
matrix.setRotate(clockwise ? 90 : -90, originalWidth >> 1, originalHeight >> 1);
|
||||
Bitmap rotatedImage = Bitmap.createBitmap(originalImage, 0, 0, originalWidth, originalHeight, matrix, true);
|
||||
|
||||
boolean rotated = false;
|
||||
if (onSdCard) {
|
||||
FileDescriptor fd = null;
|
||||
try {
|
||||
ParcelFileDescriptor pfd = activity.getContentResolver().openFileDescriptor(uri, "rw");
|
||||
if (pfd != null) fd = pfd.getFileDescriptor();
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(LOG_TAG, "failed to get file descriptor for document at uri=" + path, e);
|
||||
}
|
||||
if (fd != null) {
|
||||
try (FileOutputStream fos = new FileOutputStream(fd)) {
|
||||
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
|
||||
rotated = true;
|
||||
} catch (IOException e) {
|
||||
Log.w(LOG_TAG, "failed to save rotated image to document at uri=" + path, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try (FileOutputStream fos = new FileOutputStream(path)) {
|
||||
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
|
||||
rotated = true;
|
||||
} catch (IOException e) {
|
||||
Log.w(LOG_TAG, "failed to save rotated image to path=" + path, e);
|
||||
}
|
||||
}
|
||||
if (!rotated) {
|
||||
callback.onFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
// update fields in media store
|
||||
@SuppressWarnings("SuspiciousNameCombination") int rotatedWidth = originalHeight;
|
||||
@SuppressWarnings("SuspiciousNameCombination") int rotatedHeight = originalWidth;
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
|
||||
values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
|
||||
if (activity.getContentResolver().update(uri, values, null, null) > 0) {
|
||||
MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (p, u) -> {
|
||||
Map<String, Object> newFields = new HashMap<>();
|
||||
newFields.put("width", rotatedWidth);
|
||||
newFields.put("height", rotatedHeight);
|
||||
callback.onSuccess(newFields);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public interface ImageOpCallback {
|
||||
void onSuccess(Map<String, Object> newFields);
|
||||
|
||||
void onFailure();
|
||||
|
|
|
@ -10,9 +10,11 @@ public class Constants {
|
|||
|
||||
// mime types
|
||||
|
||||
public static final String MIME_VIDEO = "video";
|
||||
public static final String MIME_GIF = "image/gif";
|
||||
public static final String MIME_JPEG = "image/jpeg";
|
||||
public static final String MIME_PNG = "image/png";
|
||||
public static final String MIME_MP2TS = "video/mp2ts";
|
||||
public static final String MIME_VIDEO = "video";
|
||||
|
||||
// video metadata keys, from android.media.MediaMetadataRetriever
|
||||
|
||||
|
|
|
@ -5,8 +5,6 @@ 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;
|
||||
|
@ -45,7 +43,7 @@ public class Env {
|
|||
return mExternalStorage;
|
||||
}
|
||||
|
||||
public static boolean isOnSdCard(final Activity activity, @NonNull String path) {
|
||||
return !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
|
||||
public static boolean isOnSdCard(final Activity activity, String path) {
|
||||
return path != null && !getExternalStorage().equals(new PathComponents(path, getStorageVolumes(activity)).getStorage());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import android.media.ExifInterface;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class MetadataHelper {
|
||||
|
||||
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||
public static int getOrientationDegreesForExifCode(int exifOrientation) {
|
||||
switch (exifOrientation) {
|
||||
case ExifInterface.ORIENTATION_ROTATE_180: // bottom, right side
|
||||
return 180;
|
||||
case ExifInterface.ORIENTATION_ROTATE_90: // right side, top
|
||||
return 90;
|
||||
case ExifInterface.ORIENTATION_ROTATE_270: // left side, bottom
|
||||
return 270;
|
||||
}
|
||||
// all other orientations (regular, flipped...) default to an angle of 0 degree
|
||||
return 0;
|
||||
}
|
||||
|
||||
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
||||
public static long parseVideoMetadataDate(String dateString) {
|
||||
// optional sub-second
|
||||
String subSecond = null;
|
||||
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);
|
||||
if (subSecondMatcher.find()) {
|
||||
subSecond = subSecondMatcher.group(2).substring(1);
|
||||
dateString = subSecondMatcher.replaceAll("$1");
|
||||
}
|
||||
|
||||
// optional time zone
|
||||
TimeZone timeZone = null;
|
||||
Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString);
|
||||
if (timeZoneMatcher.find()) {
|
||||
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", ""));
|
||||
dateString = timeZoneMatcher.replaceAll("");
|
||||
}
|
||||
|
||||
Date date = null;
|
||||
try {
|
||||
DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
||||
parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT"));
|
||||
date = parser.parse(dateString);
|
||||
} catch (ParseException ex) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (date == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long dateMillis = date.getTime();
|
||||
if (subSecond != null) {
|
||||
try {
|
||||
int millis = (int) (Double.parseDouble("." + subSecond) * 1000);
|
||||
if (millis >= 0 && millis < 1000) {
|
||||
dateMillis += millis;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return dateMillis;
|
||||
}
|
||||
}
|
|
@ -5,8 +5,10 @@ import android.content.Context;
|
|||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
|
@ -14,6 +16,7 @@ import com.google.common.base.Splitter;
|
|||
import com.google.common.collect.Lists;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
@ -164,6 +167,34 @@ public class StorageUtils {
|
|||
return found && documentFile != null ? Optional.of(documentFile) : Optional.empty();
|
||||
}
|
||||
|
||||
public static String copyFileToTemp(String path) {
|
||||
try {
|
||||
String extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(path)).toString());
|
||||
File temp = File.createTempFile("aves", '.' + extension);
|
||||
Utils.copyFile(new File(path), temp);
|
||||
temp.deleteOnExit();
|
||||
return temp.getPath();
|
||||
} catch (IOException e) {
|
||||
Log.w(LOG_TAG, "failed to copy file at path=" + path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean writeToDocumentFile(Context context, String from, Uri documentUri) {
|
||||
try {
|
||||
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(documentUri, "rw");
|
||||
if (pfd == null) {
|
||||
Log.w(LOG_TAG, "failed to get file descriptor for documentUri=" + documentUri);
|
||||
return false;
|
||||
}
|
||||
Utils.copyFile(new File(from), pfd.getFileDescriptor());
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.w(LOG_TAG, "failed to write to DocumentFile at documentUri=" + documentUri);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the specified file on SD card
|
||||
* Note that it does not update related content providers such as the Media Store.
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
package deckers.thibault.aves.utils;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.TimeZone;
|
||||
import java.util.regex.Matcher;
|
||||
import java.io.File;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class Utils {
|
||||
|
@ -29,48 +28,27 @@ public class Utils {
|
|||
return logTag;
|
||||
}
|
||||
|
||||
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
||||
public static long parseVideoMetadataDate(String dateString) {
|
||||
// optional sub-second
|
||||
String subSecond = null;
|
||||
Matcher subSecondMatcher = Pattern.compile("(\\d{6})(\\.\\d+)").matcher(dateString);
|
||||
if (subSecondMatcher.find()) {
|
||||
subSecond = subSecondMatcher.group(2).substring(1);
|
||||
dateString = subSecondMatcher.replaceAll("$1");
|
||||
}
|
||||
|
||||
// optional time zone
|
||||
TimeZone timeZone = null;
|
||||
Matcher timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString);
|
||||
if (timeZoneMatcher.find()) {
|
||||
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replaceAll("Z", ""));
|
||||
dateString = timeZoneMatcher.replaceAll("");
|
||||
}
|
||||
|
||||
Date date = null;
|
||||
try {
|
||||
DateFormat parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.US);
|
||||
parser.setTimeZone((timeZone != null) ? timeZone : TimeZone.getTimeZone("GMT"));
|
||||
date = parser.parse(dateString);
|
||||
} catch (ParseException ex) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (date == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long dateMillis = date.getTime();
|
||||
if (subSecond != null) {
|
||||
try {
|
||||
int millis = (int) (Double.parseDouble("." + subSecond) * 1000);
|
||||
if (millis >= 0 && millis < 1000) {
|
||||
dateMillis += millis;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// ignore
|
||||
public static void copyFile(final File source, final FileDescriptor descriptor) throws IOException {
|
||||
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(descriptor)) {
|
||||
final FileChannel inChannel = inStream.getChannel();
|
||||
final FileChannel outChannel = outStream.getChannel();
|
||||
final long size = inChannel.size();
|
||||
long position = 0;
|
||||
while (position < size) {
|
||||
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void copyFile(final File source, final File destination) throws IOException {
|
||||
try (FileInputStream inStream = new FileInputStream(source); FileOutputStream outStream = new FileOutputStream(destination)) {
|
||||
final FileChannel inChannel = inStream.getChannel();
|
||||
final FileChannel outChannel = outStream.getChannel();
|
||||
final long size = inChannel.size();
|
||||
long position = 0;
|
||||
while (position < size) {
|
||||
position += inChannel.transferTo(position, 1024L * 1024L, outChannel);
|
||||
}
|
||||
}
|
||||
return dateMillis;
|
||||
}
|
||||
}
|
|
@ -15,9 +15,9 @@ class ImageEntry with ChangeNotifier {
|
|||
String path;
|
||||
int contentId;
|
||||
final String mimeType;
|
||||
final int width;
|
||||
final int height;
|
||||
final int orientationDegrees;
|
||||
int width;
|
||||
int height;
|
||||
int orientationDegrees;
|
||||
final int sizeBytes;
|
||||
String title;
|
||||
final int dateModifiedSecs;
|
||||
|
@ -205,4 +205,20 @@ class ImageEntry with ChangeNotifier {
|
|||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get canRotate => mimeType == MimeTypes.MIME_JPEG || mimeType == MimeTypes.MIME_PNG;
|
||||
|
||||
Future<bool> rotate({@required bool clockwise}) async {
|
||||
final newFields = await ImageFileService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final width = newFields['width'];
|
||||
if (width != null) this.width = width;
|
||||
final height = newFields['height'];
|
||||
if (height != null) this.height = height;
|
||||
final orientationDegrees = newFields['orientationDegrees'];
|
||||
if (orientationDegrees != null) this.orientationDegrees = orientationDegrees;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,4 +55,18 @@ class ImageFileService {
|
|||
}
|
||||
return Map();
|
||||
}
|
||||
|
||||
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
|
||||
try {
|
||||
// return map with: 'width' 'height' 'orientationDegrees' (all optional)
|
||||
final result = await platform.invokeMethod('rotate', <String, dynamic>{
|
||||
'entry': entry.toMap(),
|
||||
'clockwise': clockwise,
|
||||
}) as Map;
|
||||
return result;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('rotate failed with exception=${e.message}');
|
||||
}
|
||||
return Map();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,13 +33,14 @@ class ThumbnailState extends State<Thumbnail> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
entry.addListener(onEntryChange);
|
||||
initByteLoader();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Thumbnail oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (uri == oldWidget.entry.uri && widget.extent == oldWidget.extent) return;
|
||||
if (widget.extent == oldWidget.extent && uri == oldWidget.entry.uri && widget.entry.width == oldWidget.entry.width && widget.entry.height == oldWidget.entry.height && widget.entry.orientationDegrees == oldWidget.entry.orientationDegrees) return;
|
||||
initByteLoader();
|
||||
}
|
||||
|
||||
|
@ -48,8 +49,11 @@ class ThumbnailState extends State<Thumbnail> {
|
|||
_byteLoader = ImageFileService.getImageBytes(widget.entry, dim, dim);
|
||||
}
|
||||
|
||||
onEntryChange() => setState(() => initByteLoader());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
entry.removeListener(onEntryChange);
|
||||
ImageFileService.cancelGetImageBytes(uri);
|
||||
super.dispose();
|
||||
}
|
||||
|
@ -74,43 +78,61 @@ class ThumbnailState extends State<Thumbnail> {
|
|||
future: _byteLoader,
|
||||
builder: (futureContext, AsyncSnapshot<Uint8List> snapshot) {
|
||||
final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage;
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
children: [
|
||||
Hero(
|
||||
tag: uri,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
// during hero animation back from a fullscreen image,
|
||||
// the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints)
|
||||
// so we wrap the image to apply better constraints
|
||||
final dim = min(constraints.maxWidth, constraints.maxHeight);
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
constraints: BoxConstraints.tight(Size(dim, dim)),
|
||||
child: bytes.length > 0
|
||||
? Image.memory(
|
||||
bytes,
|
||||
width: dim,
|
||||
height: dim,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Icon(Icons.error),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (entry.isVideo)
|
||||
VideoTag(
|
||||
entry: entry,
|
||||
iconSize: iconSize,
|
||||
)
|
||||
else if (entry.isGif)
|
||||
GifTag(iconSize: iconSize)
|
||||
else if (entry.hasGps)
|
||||
GpsTag(iconSize: iconSize)
|
||||
],
|
||||
);
|
||||
return ThumbnailImage(entry: entry, bytes: bytes, iconSize: iconSize);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ThumbnailImage extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final ImageEntry entry;
|
||||
final double iconSize;
|
||||
|
||||
const ThumbnailImage({
|
||||
Key key,
|
||||
@required this.bytes,
|
||||
@required this.entry,
|
||||
@required this.iconSize,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
children: [
|
||||
Hero(
|
||||
tag: entry.uri,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
// during hero animation back from a fullscreen image,
|
||||
// the image covers the whole screen (because of the 'fit' prop and the full screen hero constraints)
|
||||
// so we wrap the image to apply better constraints
|
||||
final dim = min(constraints.maxWidth, constraints.maxHeight);
|
||||
return Container(
|
||||
alignment: Alignment.center,
|
||||
constraints: BoxConstraints.tight(Size(dim, dim)),
|
||||
child: bytes.length > 0
|
||||
? Image.memory(
|
||||
bytes,
|
||||
width: dim,
|
||||
height: dim,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Icon(Icons.error),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (entry.isVideo)
|
||||
VideoTag(
|
||||
entry: entry,
|
||||
iconSize: iconSize,
|
||||
)
|
||||
else if (entry.isGif)
|
||||
GifTag(iconSize: iconSize)
|
||||
else if (entry.hasGps)
|
||||
GpsTag(iconSize: iconSize)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -213,6 +213,56 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
}
|
||||
}
|
||||
|
||||
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
||||
switch (action) {
|
||||
case FullscreenAction.edit:
|
||||
AndroidAppService.edit(entry.uri, entry.mimeType);
|
||||
break;
|
||||
case FullscreenAction.info:
|
||||
goToVerticalPage(1);
|
||||
break;
|
||||
case FullscreenAction.rename:
|
||||
showRenameDialog(entry);
|
||||
break;
|
||||
case FullscreenAction.open:
|
||||
AndroidAppService.open(entry.uri, entry.mimeType);
|
||||
break;
|
||||
case FullscreenAction.openMap:
|
||||
AndroidAppService.openMap(entry.geoUri);
|
||||
break;
|
||||
case FullscreenAction.rotateCCW:
|
||||
rotate(entry, clockwise: false);
|
||||
break;
|
||||
case FullscreenAction.rotateCW:
|
||||
rotate(entry, clockwise: true);
|
||||
break;
|
||||
case FullscreenAction.setAs:
|
||||
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
||||
break;
|
||||
case FullscreenAction.share:
|
||||
AndroidAppService.share(entry.uri, entry.mimeType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showFeedback(String message) {
|
||||
Flushbar(
|
||||
message: message,
|
||||
margin: EdgeInsets.all(8),
|
||||
borderRadius: 8,
|
||||
borderColor: Colors.white30,
|
||||
borderWidth: 0.5,
|
||||
duration: Duration(seconds: 2),
|
||||
flushbarPosition: FlushbarPosition.TOP,
|
||||
animationDuration: Duration(milliseconds: 600),
|
||||
)..show(context);
|
||||
}
|
||||
|
||||
rotate(ImageEntry entry, {@required bool clockwise}) async {
|
||||
final success = await entry.rotate(clockwise: clockwise);
|
||||
showFeedback(success ? 'Done!' : 'Failed');
|
||||
}
|
||||
|
||||
showRenameDialog(ImageEntry entry) async {
|
||||
final currentName = entry.title;
|
||||
final controller = TextEditingController(text: currentName);
|
||||
|
@ -238,46 +288,11 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
|
|||
});
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
final success = await entry.rename(newName);
|
||||
Flushbar(
|
||||
message: success ? 'Done!' : 'Failed',
|
||||
margin: EdgeInsets.all(8),
|
||||
borderRadius: 8,
|
||||
borderColor: Colors.white30,
|
||||
borderWidth: 0.5,
|
||||
duration: Duration(seconds: 2),
|
||||
flushbarPosition: FlushbarPosition.TOP,
|
||||
animationDuration: Duration(milliseconds: 600),
|
||||
)..show(context);
|
||||
}
|
||||
|
||||
onActionSelected(ImageEntry entry, FullscreenAction action) {
|
||||
switch (action) {
|
||||
case FullscreenAction.edit:
|
||||
AndroidAppService.edit(entry.uri, entry.mimeType);
|
||||
break;
|
||||
case FullscreenAction.info:
|
||||
goToVerticalPage(1);
|
||||
break;
|
||||
case FullscreenAction.rename:
|
||||
showRenameDialog(entry);
|
||||
break;
|
||||
case FullscreenAction.open:
|
||||
AndroidAppService.open(entry.uri, entry.mimeType);
|
||||
break;
|
||||
case FullscreenAction.openMap:
|
||||
AndroidAppService.openMap(entry.geoUri);
|
||||
break;
|
||||
case FullscreenAction.setAs:
|
||||
AndroidAppService.setAs(entry.uri, entry.mimeType);
|
||||
break;
|
||||
case FullscreenAction.share:
|
||||
AndroidAppService.share(entry.uri, entry.mimeType);
|
||||
break;
|
||||
}
|
||||
showFeedback(success ? 'Done!' : 'Failed');
|
||||
}
|
||||
}
|
||||
|
||||
enum FullscreenAction { edit, info, open, openMap, rename, setAs, share }
|
||||
enum FullscreenAction { edit, info, open, openMap, rename, rotateCCW, rotateCW, setAs, share }
|
||||
|
||||
class ImagePage extends StatefulWidget {
|
||||
final List<ImageEntry> entries;
|
||||
|
|
|
@ -50,29 +50,39 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.info,
|
||||
child: Text("Info"),
|
||||
child: MenuRow(text: 'Info', icon: Icons.info_outline),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.rename,
|
||||
child: Text("Rename"),
|
||||
child: MenuRow(text: 'Rename', icon: Icons.title),
|
||||
),
|
||||
if (entry.canRotate)
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.rotateCCW,
|
||||
child: MenuRow(text: 'Rotate left', icon: Icons.rotate_left),
|
||||
),
|
||||
if (entry.canRotate)
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.rotateCW,
|
||||
child: MenuRow(text: 'Rotate right', icon: Icons.rotate_right),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.edit,
|
||||
child: Text("Edit with…"),
|
||||
child: Text('Edit with…'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.open,
|
||||
child: Text("Open with…"),
|
||||
child: Text('Open with…'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.setAs,
|
||||
child: Text("Set as…"),
|
||||
child: Text('Set as…'),
|
||||
),
|
||||
if (entry.hasGps)
|
||||
PopupMenuItem(
|
||||
value: FullscreenAction.openMap,
|
||||
child: Text("Show on map…"),
|
||||
child: Text('Show on map…'),
|
||||
),
|
||||
],
|
||||
onSelected: onActionSelected,
|
||||
|
@ -85,6 +95,28 @@ class FullscreenTopOverlay extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class MenuRow extends StatelessWidget {
|
||||
final String text;
|
||||
final IconData icon;
|
||||
|
||||
const MenuRow({
|
||||
Key key,
|
||||
this.text,
|
||||
this.icon,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon),
|
||||
SizedBox(width: 8),
|
||||
Text(text),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayButton extends StatelessWidget {
|
||||
final Animation<double> scale;
|
||||
final Widget child;
|
||||
|
|
Loading…
Reference in a new issue