Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-10-09 15:54:22 +09:00
commit 13921b090a
58 changed files with 2296 additions and 1399 deletions

View file

@ -14,8 +14,8 @@ jobs:
steps:
- uses: subosito/flutter-action@v1
with:
channel: beta
flutter-version: '1.22.0-12.1.pre'
channel: stable
flutter-version: '1.22.1'
- name: Clone the repository.
uses: actions/checkout@v2

View file

@ -16,8 +16,8 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: beta
flutter-version: '1.22.0-12.1.pre'
channel: stable
flutter-version: '1.22.1'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
@ -50,8 +50,8 @@ jobs:
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc
flutter build apk --bundle-sksl-path shaders_1.22.0-12.1.pre.sksl.json
flutter build appbundle --bundle-sksl-path shaders_1.22.0-12.1.pre.sksl.json
flutter build apk --bundle-sksl-path shaders_1.22.1.sksl.json
flutter build appbundle --bundle-sksl-path shaders_1.22.1.sksl.json
rm $AVES_STORE_FILE
env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks

View file

@ -52,10 +52,12 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
static class Result {
Params params;
byte[] data;
String errorDetails;
Result(Params params, byte[] data) {
Result(Params params, byte[] data, String errorDetails) {
this.params = params;
this.data = data;
this.errorDetails = errorDetails;
}
}
@ -70,8 +72,8 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
protected Result doInBackground(Params... params) {
Params p = params[0];
Bitmap bitmap = null;
Exception exception = null;
if (!this.isCancelled()) {
Exception exception = null;
Integer w = p.width;
Integer h = p.height;
// fetch low quality thumbnails when size is not specified
@ -97,13 +99,10 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
} catch (Exception e) {
exception = e;
}
if (bitmap == null) {
Log.e(LOG_TAG, "failed to load thumbnail for uri=" + p.entry.uri + ", path=" + p.entry.path, exception);
}
} else {
Log.d(LOG_TAG, "getThumbnail with uri=" + p.entry.uri + " cancelled");
}
byte[] data = null;
if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
@ -112,7 +111,15 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
data = stream.toByteArray();
}
return new Result(p, data);
String errorDetails = null;
if (exception != null) {
errorDetails = exception.getMessage();
if (errorDetails != null && !errorDetails.isEmpty()) {
errorDetails = errorDetails.split("\n", 2)[0];
}
}
return new Result(p, data, errorDetails);
}
@RequiresApi(api = Build.VERSION_CODES.Q)
@ -124,8 +131,8 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
ContentResolver resolver = activity.getContentResolver();
Bitmap bitmap = resolver.loadThumbnail(entry.uri, new Size(width, height), null);
String mimeType = entry.mimeType;
if (MimeTypes.DNG.equals(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.orientationDegrees);
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
}
return bitmap;
}
@ -141,7 +148,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
Bitmap bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null);
// from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = rotateBitmap(bitmap, entry.orientationDegrees);
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
}
return bitmap;
}
@ -153,7 +160,7 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
int height = params.height;
// add signature to ignore cache for images which got modified but kept the same URI
Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.orientationDegrees);
Key signature = new ObjectKey("" + entry.dateModifiedSecs + entry.width + entry.rotationDegrees);
RequestOptions options = new RequestOptions()
.signature(signature)
.override(width, height);
@ -179,8 +186,8 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
try {
Bitmap bitmap = target.get();
String mimeType = entry.mimeType;
if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.orientationDegrees);
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = rotateBitmap(bitmap, entry.rotationDegrees);
}
return bitmap;
} finally {
@ -188,21 +195,24 @@ public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, Ima
}
}
private Bitmap rotateBitmap(Bitmap bitmap, Integer orientationDegrees) {
if (bitmap != null && orientationDegrees != null) {
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
private Bitmap rotateBitmap(Bitmap bitmap, Integer rotationDegrees) {
if (bitmap != null && rotationDegrees != null) {
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
}
return bitmap;
}
@Override
protected void onPostExecute(Result result) {
MethodChannel.Result r = result.params.result;
String uri = result.params.entry.uri.toString();
Params params = result.params;
MethodChannel.Result r = params.result;
AvesImageEntry entry = params.entry;
String uri = entry.uri.toString();
if (result.data != null) {
r.success(result.data);
} else {
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri, null);
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri + ", path=" + entry.path, result.errorDetails);
}
}
}

View file

@ -95,17 +95,17 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String uriString = call.argument("uri");
String mimeType = call.argument("mimeType");
if (uriString == null || mimeType == null) {
Uri uri = Uri.parse(call.argument("uri"));
if (uri == null || mimeType == null) {
result.error("getImageEntry-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse(uriString);
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("getImageEntry-provider", "failed to find provider for uri=" + uriString, null);
result.error("getImageEntry-provider", "failed to find provider for uri=" + uri, null);
return;
}
@ -117,7 +117,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
@Override
public void onFailure(Throwable throwable) {
result.error("getImageEntry-failure", "failed to get entry for uri=" + uriString, throwable.getMessage());
result.error("getImageEntry-failure", "failed to get entry for uri=" + uri, throwable.getMessage());
}
});
}

View file

@ -1,629 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.provider.MediaStore;
import android.text.format.Formatter;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.adobe.internal.xmp.XMPException;
import com.adobe.internal.xmp.XMPIterator;
import com.adobe.internal.xmp.XMPMeta;
import com.adobe.internal.xmp.XMPUtils;
import com.adobe.internal.xmp.properties.XMPProperty;
import com.adobe.internal.xmp.properties.XMPPropertyInfo;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.imaging.jpeg.JpegMetadataReader;
import com.drew.imaging.jpeg.JpegSegmentMetadataReader;
import com.drew.imaging.jpeg.JpegSegmentType;
import com.drew.lang.GeoLocation;
import com.drew.lang.Rational;
import com.drew.lang.annotations.NotNull;
import com.drew.metadata.Directory;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.Tag;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifReader;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.ExifThumbnailDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.drew.metadata.file.FileTypeDirectory;
import com.drew.metadata.gif.GifAnimationDirectory;
import com.drew.metadata.webp.WebpDirectory;
import com.drew.metadata.xmp.XmpDirectory;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class MetadataHandler implements MethodChannel.MethodCallHandler {
private static final String LOG_TAG = Utils.createLogTag(MetadataHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/metadata";
// catalog metadata
private static final String KEY_MIME_TYPE = "mimeType";
private static final String KEY_DATE_MILLIS = "dateMillis";
private static final String KEY_IS_ANIMATED = "isAnimated";
private static final String KEY_LATITUDE = "latitude";
private static final String KEY_LONGITUDE = "longitude";
private static final String KEY_VIDEO_ROTATION = "videoRotation";
private static final String KEY_XMP_SUBJECTS = "xmpSubjects";
private static final String KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription";
// overlay metadata
private static final String KEY_APERTURE = "aperture";
private static final String KEY_EXPOSURE_TIME = "exposureTime";
private static final String KEY_FOCAL_LENGTH = "focalLength";
private static final String KEY_ISO = "iso";
// XMP
private static final String XMP_DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/";
private static final String XMP_XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/";
private static final String XMP_IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/";
private static final String XMP_SUBJECT_PROP_NAME = "dc:subject";
private static final String XMP_TITLE_PROP_NAME = "dc:title";
private static final String XMP_DESCRIPTION_PROP_NAME = "dc:description";
private static final String XMP_THUMBNAIL_PROP_NAME = "xmp:Thumbnails";
private static final String XMP_THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image";
private static final String XMP_GENERIC_LANG = "";
private static final String XMP_SPECIFIC_LANG = "en-US";
// video metadata keys, from android.media.MediaMetadataRetriever
private static final Map<Integer, String> VIDEO_MEDIA_METADATA_KEYS = new HashMap<Integer, String>() {
{
put(MediaMetadataRetriever.METADATA_KEY_ALBUM, "Album");
put(MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, "Album Artist");
put(MediaMetadataRetriever.METADATA_KEY_ARTIST, "Artist");
put(MediaMetadataRetriever.METADATA_KEY_AUTHOR, "Author");
put(MediaMetadataRetriever.METADATA_KEY_BITRATE, "Bitrate");
put(MediaMetadataRetriever.METADATA_KEY_COMPOSER, "Composer");
put(MediaMetadataRetriever.METADATA_KEY_DATE, "Date");
put(MediaMetadataRetriever.METADATA_KEY_GENRE, "Content Type");
put(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO, "Has Audio");
put(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO, "Has Video");
put(MediaMetadataRetriever.METADATA_KEY_LOCATION, "Location");
put(MediaMetadataRetriever.METADATA_KEY_MIMETYPE, "MIME Type");
put(MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS, "Number of Tracks");
put(MediaMetadataRetriever.METADATA_KEY_TITLE, "Title");
put(MediaMetadataRetriever.METADATA_KEY_WRITER, "Writer");
put(MediaMetadataRetriever.METADATA_KEY_YEAR, "Year");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
put(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT, "Frame Count");
}
// TODO TLAD comment? category?
}
};
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples:
// "+37.5090+127.0243/" (Samsung)
// "+51.3328-000.7053+113.474/" (Apple)
private static final Pattern VIDEO_LOCATION_PATTERN = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*");
private static int TAG_THUMBNAIL_DATA = 0x10000;
// modify metadata-extractor readers to store EXIF thumbnail data
// cf https://github.com/drewnoakes/metadata-extractor/issues/276#issuecomment-677767368
static {
List<JpegSegmentMetadataReader> allReaders = (List<JpegSegmentMetadataReader>) JpegMetadataReader.ALL_READERS;
for (int n = 0, cnt = allReaders.size(); n < cnt; n++) {
if (allReaders.get(n).getClass() != ExifReader.class) {
continue;
}
allReaders.set(n, new ExifReader() {
@Override
public void readJpegSegments(@NotNull final Iterable<byte[]> segments, @NotNull final Metadata metadata, @NotNull final JpegSegmentType segmentType) {
super.readJpegSegments(segments, metadata, segmentType);
for (byte[] segmentBytes : segments) {
// Filter any segments containing unexpected preambles
if (!startsWithJpegExifPreamble(segmentBytes)) {
continue;
}
// Extract the thumbnail
try {
ExifThumbnailDirectory tnDirectory = metadata.getFirstDirectoryOfType(ExifThumbnailDirectory.class);
if (tnDirectory != null && tnDirectory.containsTag(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET)) {
int offset = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET);
int length = tnDirectory.getInt(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH);
byte[] tnData = new byte[length];
System.arraycopy(segmentBytes, JPEG_SEGMENT_PREAMBLE.length() + offset, tnData, 0, length);
tnDirectory.setObject(TAG_THUMBNAIL_DATA, tnData);
}
} catch (MetadataException e) {
e.printStackTrace();
}
}
}
});
break;
}
}
private Context context;
public MetadataHandler(Context context) {
this.context = context;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getAllMetadata":
new Thread(() -> getAllMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getCatalogMetadata":
new Thread(() -> getCatalogMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getOverlayMetadata":
new Thread(() -> getOverlayMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getContentResolverMetadata":
new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start();
break;
case "getEmbeddedPictures":
new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start();
break;
case "getExifThumbnails":
new Thread(() -> getExifThumbnails(call, new MethodResultWrapper(result))).start();
break;
case "getXmpThumbnails":
new Thread(() -> getXmpThumbnails(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
break;
}
}
private boolean isVideo(@Nullable String mimeType) {
return mimeType != null && mimeType.startsWith(MimeTypes.VIDEO);
}
private void getAllMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String uri = call.argument("uri");
Map<String, Map<String, String>> metadataMap = new HashMap<>();
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (Directory dir : metadata.getDirectories()) {
if (dir.getTagCount() > 0) {
// directory name
String dirName = dir.getName();
Map<String, String> dirMap = Objects.requireNonNull(metadataMap.getOrDefault(dirName, new HashMap<>()));
metadataMap.put(dirName, dirMap);
// tags
for (Tag tag : dir.getTags()) {
dirMap.put(tag.getTagName(), tag.getDescription());
}
if (dir instanceof XmpDirectory) {
try {
XmpDirectory xmpDir = (XmpDirectory) dir;
XMPMeta xmpMeta = xmpDir.getXMPMeta();
xmpMeta.sort();
XMPIterator xmpIterator = xmpMeta.iterator();
while (xmpIterator.hasNext()) {
XMPPropertyInfo prop = (XMPPropertyInfo) xmpIterator.next();
String xmpPath = prop.getPath();
String xmpValue = prop.getValue();
if (xmpPath != null && !xmpPath.isEmpty() && xmpValue != null && !xmpValue.isEmpty()) {
dirMap.put(xmpPath, xmpValue);
}
}
} catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
}
}
} catch (Exception | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get video metadata by ImageMetadataReader for uri=" + uri, e);
}
if (isVideo(mimeType)) {
Map<String, String> videoDir = getVideoAllMetadataByMediaMetadataRetriever(uri);
if (!videoDir.isEmpty()) {
metadataMap.put("Video", videoDir);
}
}
if (metadataMap.isEmpty()) {
result.error("getAllMetadata-failure", "failed to get metadata for uri=" + uri, null);
} else {
result.success(metadataMap);
}
}
private Map<String, String> getVideoAllMetadataByMediaMetadataRetriever(String uri) {
Map<String, String> dirMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri));
if (retriever != null) {
try {
for (Map.Entry<Integer, String> kv : VIDEO_MEDIA_METADATA_KEYS.entrySet()) {
Integer key = kv.getKey();
String value = retriever.extractMetadata(key);
if (value != null) {
switch (key) {
case MediaMetadataRetriever.METADATA_KEY_BITRATE:
value = Formatter.formatFileSize(context, Long.parseLong(value)) + "/sec";
break;
case MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION:
value += "°";
break;
}
dirMap.put(kv.getValue(), value);
}
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=" + uri, e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
return dirMap;
}
private void getCatalogMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String uri = call.argument("uri");
Map<String, Object> metadataMap = new HashMap<>(getCatalogMetadataByImageMetadataReader(uri, mimeType));
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri));
}
if (metadataMap.isEmpty()) {
result.error("getCatalogMetadata-failure", "failed to get catalog metadata for uri=" + uri, null);
} else {
result.success(metadataMap);
}
}
private Map<String, Object> getCatalogMetadataByImageMetadataReader(String uri, String mimeType) {
Map<String, Object> metadataMap = new HashMap<>();
// as of metadata-extractor 2.14.0, MP2T files are not supported
if (MimeTypes.MP2T.equals(mimeType)) return metadataMap;
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// File type
for (FileTypeDirectory dir : metadata.getDirectoriesOfType(FileTypeDirectory.class)) {
// the reported `mimeType` (e.g. from Media Store) is sometimes incorrect
// file extension is unreliable
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
metadataMap.put(KEY_MIME_TYPE, dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE));
}
}
// EXIF
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifSubIFDDirectory.class, ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
putDateFromDirectoryTag(metadataMap, KEY_DATE_MILLIS, metadata, ExifIFD0Directory.class, ExifIFD0Directory.TAG_DATETIME);
}
// GPS
for (GpsDirectory dir : metadata.getDirectoriesOfType(GpsDirectory.class)) {
GeoLocation geoLocation = dir.getGeoLocation();
if (geoLocation != null) {
metadataMap.put(KEY_LATITUDE, geoLocation.getLatitude());
metadataMap.put(KEY_LONGITUDE, geoLocation.getLongitude());
}
}
// XMP
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta();
try {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME)) {
StringBuilder sb = new StringBuilder();
int count = xmpMeta.countArrayItems(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME);
for (int i = 1; i < count + 1; i++) {
XMPProperty item = xmpMeta.getArrayItem(XMP_DC_SCHEMA_NS, XMP_SUBJECT_PROP_NAME, i);
sb.append(";").append(item.getValue());
}
metadataMap.put(KEY_XMP_SUBJECTS, sb.toString());
}
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_TITLE_PROP_NAME);
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
putLocalizedTextFromXmp(metadataMap, KEY_XMP_TITLE_DESCRIPTION, xmpMeta, XMP_DESCRIPTION_PROP_NAME);
}
} catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
// Animated GIF & WEBP
if (MimeTypes.GIF.equals(mimeType)) {
metadataMap.put(KEY_IS_ANIMATED, metadata.containsDirectoryOfType(GifAnimationDirectory.class));
} else if (MimeTypes.WEBP.equals(mimeType)) {
for (WebpDirectory dir : metadata.getDirectoriesOfType(WebpDirectory.class)) {
if (dir.containsTag(WebpDirectory.TAG_IS_ANIMATION)) {
metadataMap.put(KEY_IS_ANIMATED, dir.getBoolean(WebpDirectory.TAG_IS_ANIMATION));
}
}
}
} catch (Exception | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to get catalog metadata by ImageMetadataReader for uri=" + uri, e);
}
return metadataMap;
}
private Map<String, Object> getVideoCatalogMetadataByMediaMetadataRetriever(String uri) {
Map<String, Object> metadataMap = new HashMap<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, Uri.parse(uri));
if (retriever != null) {
try {
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
String rotationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
String locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION);
if (dateString != null) {
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
metadataMap.put(KEY_DATE_MILLIS, dateMillis);
}
}
if (rotationString != null) {
metadataMap.put(KEY_VIDEO_ROTATION, Integer.parseInt(rotationString));
}
if (locationString != null) {
Matcher locationMatcher = VIDEO_LOCATION_PATTERN.matcher(locationString);
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
String latitudeString = locationMatcher.group(1);
String longitudeString = locationMatcher.group(2);
if (latitudeString != null && longitudeString != null) {
try {
double latitude = Double.parseDouble(latitudeString);
double longitude = Double.parseDouble(longitudeString);
if (latitude != 0 && longitude != 0) {
metadataMap.put(KEY_LATITUDE, latitude);
metadataMap.put(KEY_LONGITUDE, longitude);
}
} catch (NumberFormatException e) {
// ignore
}
}
}
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=" + uri, e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
return metadataMap;
}
private void getOverlayMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String uri = call.argument("uri");
Map<String, Object> metadataMap = new HashMap<>();
if (isVideo(mimeType)) {
result.success(metadataMap);
return;
}
try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uri))) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (ExifSubIFDDirectory directory : metadata.getDirectoriesOfType(ExifSubIFDDirectory.class)) {
putDescriptionFromTag(metadataMap, KEY_APERTURE, directory, ExifSubIFDDirectory.TAG_FNUMBER);
putDescriptionFromTag(metadataMap, KEY_FOCAL_LENGTH, directory, ExifSubIFDDirectory.TAG_FOCAL_LENGTH);
if (directory.containsTag(ExifSubIFDDirectory.TAG_EXPOSURE_TIME)) {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
Rational rational = directory.getRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME);
long num = rational.getNumerator();
long denom = rational.getDenominator();
if (num > denom) {
metadataMap.put(KEY_EXPOSURE_TIME, rational.toSimpleString(true) + "");
} else {
if (num != 1 && num != 0) {
rational = new Rational(1, Math.round(denom / (double) num));
}
metadataMap.put(KEY_EXPOSURE_TIME, rational.toString());
}
}
if (directory.containsTag(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT)) {
metadataMap.put(KEY_ISO, "ISO" + directory.getDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT));
}
}
result.success(metadataMap);
} catch (Exception | NoClassDefFoundError e) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=" + uri, e.getMessage());
}
}
private void getContentResolverMetadata(MethodCall call, MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
String uriString = call.argument("uri");
if (mimeType == null || uriString == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse(uriString);
long id = ContentUris.parseId(uri);
Uri contentUri = uri;
if (mimeType.startsWith(MimeTypes.IMAGE)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri);
}
Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
Map<String, Object> metadataMap = new HashMap<>();
int columnCount = cursor.getColumnCount();
String[] columnNames = cursor.getColumnNames();
for (int i = 0; i < columnCount; i++) {
String key = columnNames[i];
try {
switch (cursor.getType(i)) {
case Cursor.FIELD_TYPE_NULL:
default:
metadataMap.put(key, null);
break;
case Cursor.FIELD_TYPE_INTEGER:
metadataMap.put(key, cursor.getLong(i));
break;
case Cursor.FIELD_TYPE_FLOAT:
metadataMap.put(key, cursor.getFloat(i));
break;
case Cursor.FIELD_TYPE_STRING:
metadataMap.put(key, cursor.getString(i));
break;
case Cursor.FIELD_TYPE_BLOB:
metadataMap.put(key, cursor.getBlob(i));
break;
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to get value for key=" + key, e);
}
}
cursor.close();
result.success(metadataMap);
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=" + contentUri, null);
}
}
private void getEmbeddedPictures(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> pictures = new ArrayList<>();
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) {
try {
byte[] picture = retriever.getEmbeddedPicture();
if (picture != null) {
pictures.add(picture);
}
} catch (Exception e) {
result.error("getVideoEmbeddedPictures-failure", "failed to get embedded picture for uri=" + uri, e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
result.success(pictures);
}
private void getExifThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>();
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (ExifThumbnailDirectory dir : metadata.getDirectoriesOfType(ExifThumbnailDirectory.class)) {
byte[] data = (byte[]) dir.getObject(TAG_THUMBNAIL_DATA);
if (data != null) {
thumbnails.add(data);
}
}
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to extract exif thumbnail", e);
}
result.success(thumbnails);
}
private void getXmpThumbnails(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Uri uri = Uri.parse(call.argument("uri"));
List<byte[]> thumbnails = new ArrayList<>();
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
for (XmpDirectory dir : metadata.getDirectoriesOfType(XmpDirectory.class)) {
XMPMeta xmpMeta = dir.getXMPMeta();
try {
if (xmpMeta.doesPropertyExist(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME)) {
int count = xmpMeta.countArrayItems(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME);
for (int i = 1; i < count + 1; i++) {
XMPProperty image = xmpMeta.getStructField(XMP_XMP_SCHEMA_NS, XMP_THUMBNAIL_PROP_NAME + "[" + i + "]", XMP_IMG_SCHEMA_NS, XMP_THUMBNAIL_IMAGE_PROP_NAME);
if (image != null) {
thumbnails.add(XMPUtils.decodeBase64(image.getValue()));
}
}
}
} catch (XMPException e) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=" + uri, e);
}
}
} catch (IOException | ImageProcessingException | NoClassDefFoundError e) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e);
}
result.success(thumbnails);
}
// convenience methods
private static <T extends Directory> void putDateFromDirectoryTag(Map<String, Object> metadataMap, String key, Metadata metadata, Class<T> dirClass, int tag) {
for (T dir : metadata.getDirectoriesOfType(dirClass)) {
putDateFromTag(metadataMap, key, dir, tag);
}
}
private static void putDateFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getDate(tag, null, TimeZone.getDefault()).getTime());
}
}
private static void putDescriptionFromTag(Map<String, Object> metadataMap, String key, Directory dir, int tag) {
if (dir.containsTag(tag)) {
metadataMap.put(key, dir.getDescription(tag));
}
}
private static void putLocalizedTextFromXmp(Map<String, Object> metadataMap, String key, XMPMeta xmpMeta, String propName) throws XMPException {
if (xmpMeta.doesPropertyExist(XMP_DC_SCHEMA_NS, propName)) {
XMPProperty item = xmpMeta.getLocalizedText(XMP_DC_SCHEMA_NS, propName, XMP_GENERIC_LANG, XMP_SPECIFIC_LANG);
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) {
metadataMap.put(key, item.getValue());
}
}
}
}

View file

@ -28,7 +28,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private Activity activity;
private Uri uri;
private String mimeType;
private int orientationDegrees;
private int rotationDegrees;
private EventChannel.EventSink eventSink;
private Handler handler;
@ -39,7 +39,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
this.orientationDegrees = (int) argMap.get("orientationDegrees");
this.rotationDegrees = (int) argMap.get("rotationDegrees");
}
}
@ -71,7 +71,7 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
// - Android: https://developer.android.com/guide/topics/media/media-formats#image-formats
// - Glide: https://github.com/bumptech/glide/blob/master/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java
private void getImage() {
if (mimeType != null && mimeType.startsWith(MimeTypes.VIDEO)) {
if (MimeTypes.isVideo(mimeType)) {
RequestOptions options = new RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
FutureTarget<Bitmap> target = Glide.with(activity)
@ -95,9 +95,8 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
} finally {
Glide.with(activity).clear(target);
}
} else if (MimeTypes.DNG.equals(mimeType) || MimeTypes.HEIC.equals(mimeType) || MimeTypes.HEIF.equals(mimeType)) {
// as of Flutter v1.20, Dart Image.memory cannot decode DNG/HEIC/HEIF images
// so we convert the image on platform side first
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees)) {
// we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.load(uri)
@ -105,7 +104,8 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
try {
Bitmap bitmap = target.get();
if (bitmap != null) {
bitmap = TransformationUtils.rotateImage(bitmap, orientationDegrees);
// TODO TLAD use exif orientation to rotate & flip?
bitmap = TransformationUtils.rotateImage(bitmap, rotationDegrees);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
@ -115,7 +115,11 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, e.getMessage());
String errorDetails = e.getMessage();
if (errorDetails != null && !errorDetails.isEmpty()) {
errorDetails = errorDetails.split("\n", 2)[0];
}
error("getImage-image-decode-exception", "failed to get image from uri=" + uri, errorDetails);
} finally {
Glide.with(activity).clear(target);
}

View file

@ -0,0 +1,40 @@
package deckers.thibault.aves.decoder;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import org.jetbrains.annotations.NotNull;
@GlideModule
public class AvesAppGlideModule extends AppGlideModule {
@Override
public void applyOptions(@NotNull Context context, @NonNull GlideBuilder builder) {
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR);
}
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
// prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383
glide.getRegistry().getImageHeaderParsers().removeIf(parser -> parser instanceof ExifInterfaceImageHeaderParser);
}
@Override
public boolean isManifestParsingEnabled() {
return false;
}
}

View file

@ -1,23 +0,0 @@
package deckers.thibault.aves.decoder;
import android.content.Context;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser;
import com.bumptech.glide.module.LibraryGlideModule;
@GlideModule
public class NoExifInterfaceGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
super.registerComponents(context, glide, registry);
// prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383
glide.getRegistry().getImageHeaderParsers().removeIf(parser -> parser instanceof ExifInterfaceImageHeaderParser);
}
}

View file

@ -5,29 +5,16 @@ import android.content.Context;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.module.LibraryGlideModule;
import java.io.InputStream;
@GlideModule
public class VideoThumbnailGlideModule extends AppGlideModule {
@Override
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
}
public class VideoThumbnailGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
registry.append(VideoThumbnail.class, InputStream.class, new VideoThumbnailLoader.Factory());
}
@Override
public boolean isManifestParsingEnabled() {
return false;
}
}

View file

@ -1,41 +0,0 @@
package deckers.thibault.aves.model;
import android.net.Uri;
import androidx.annotation.Nullable;
import java.util.Map;
import deckers.thibault.aves.utils.MimeTypes;
public class AvesImageEntry {
public Uri uri; // content or file URI
public String path; // best effort to get local path
public String mimeType;
@Nullable
public Integer width, height, orientationDegrees;
@Nullable
public Long dateModifiedSecs;
public AvesImageEntry(Map<String, Object> map) {
this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path");
this.mimeType = (String) map.get("mimeType");
this.width = (Integer) map.get("width");
this.height = (Integer) map.get("height");
this.orientationDegrees = (Integer) map.get("orientationDegrees");
this.dateModifiedSecs = toLong(map.get("dateModifiedSecs"));
}
public boolean isVideo() {
return mimeType.startsWith(MimeTypes.VIDEO);
}
// convenience method
private static Long toLong(Object o) {
if (o == null) return null;
if (o instanceof Integer) return Long.valueOf((Integer) o);
return (long) o;
}
}

View file

@ -1,288 +0,0 @@
package deckers.thibault.aves.model;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.avi.AviDirectory;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.jpeg.JpegDirectory;
import com.drew.metadata.mp4.Mp4Directory;
import com.drew.metadata.mp4.media.Mp4VideoDirectory;
import com.drew.metadata.photoshop.PsdHeaderDirectory;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.TimeZone;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
public class SourceImageEntry {
public Uri uri; // content or file URI
public String path; // best effort to get local path
public String sourceMimeType;
@Nullable
public String title;
@Nullable
public Integer width, height, orientationDegrees;
@Nullable
public Long sizeBytes;
@Nullable
public Long dateModifiedSecs;
@Nullable
private Long sourceDateTakenMillis;
@Nullable
private Long durationMillis;
public SourceImageEntry() {
}
public SourceImageEntry(@NonNull Map<String, Object> map) {
this.uri = Uri.parse((String) map.get("uri"));
this.path = (String) map.get("path");
this.sourceMimeType = (String) map.get("sourceMimeType");
this.width = (int) map.get("width");
this.height = (int) map.get("height");
this.orientationDegrees = (int) map.get("orientationDegrees");
this.sizeBytes = toLong(map.get("sizeBytes"));
this.title = (String) map.get("title");
this.dateModifiedSecs = toLong(map.get("dateModifiedSecs"));
this.sourceDateTakenMillis = toLong(map.get("sourceDateTakenMillis"));
this.durationMillis = toLong(map.get("durationMillis"));
}
public Map<String, Object> toMap() {
return new HashMap<String, Object>() {{
put("uri", uri.toString());
put("path", path);
put("sourceMimeType", sourceMimeType);
put("width", width);
put("height", height);
put("orientationDegrees", orientationDegrees != null ? orientationDegrees : 0);
put("sizeBytes", sizeBytes);
put("title", title);
put("dateModifiedSecs", dateModifiedSecs);
put("sourceDateTakenMillis", sourceDateTakenMillis);
put("durationMillis", durationMillis);
// only for map export
put("contentId", getContentId());
}};
}
private Long getContentId() {
if (uri != null && ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
try {
return ContentUris.parseId(uri);
} catch (NumberFormatException | UnsupportedOperationException e) {
// ignore when the ID is not a number
// e.g. content://com.sec.android.app.myfiles.FileProvider/device_storage/20200109_162621.jpg
}
}
return null;
}
public boolean hasSize() {
return width != null && width > 0 && height != null && height > 0;
}
private boolean hasDuration() {
return durationMillis != null && durationMillis > 0;
}
private boolean isImage() {
return sourceMimeType.startsWith(MimeTypes.IMAGE);
}
public boolean isSvg() {
return sourceMimeType.equals(MimeTypes.SVG);
}
private boolean isVideo() {
return sourceMimeType.startsWith(MimeTypes.VIDEO);
}
// metadata retrieval
// expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration
public SourceImageEntry fillPreCatalogMetadata(@NonNull Context context) {
fillByMediaMetadataRetriever(context);
if (hasSize() && (!isVideo() || hasDuration())) return this;
fillByMetadataExtractor(context);
if (hasSize()) return this;
fillByBitmapDecode(context);
return this;
}
// expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration
private void fillByMediaMetadataRetriever(@NonNull Context context) {
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(context, uri);
if (retriever != null) {
try {
String width = null, height = null, rotation = null, durationMillis = null;
if (isImage()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH);
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT);
rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION);
}
} else if (isVideo()) {
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
durationMillis = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
}
if (width != null) {
this.width = Integer.parseInt(width);
}
if (height != null) {
this.height = Integer.parseInt(height);
}
if (rotation != null) {
this.orientationDegrees = Integer.parseInt(rotation);
}
if (durationMillis != null) {
this.durationMillis = Long.parseLong(durationMillis);
}
String dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
long dateMillis = MetadataHelper.parseVideoMetadataDate(dateString);
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
this.sourceDateTakenMillis = dateMillis;
}
String title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
if (title != null) {
this.title = title;
}
} catch (Exception e) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
}
// expects entry with: uri, mimeType
// finds: width, height, orientation, date
private void fillByMetadataExtractor(@NonNull Context context) {
if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
switch (sourceMimeType) {
case MimeTypes.JPEG:
for (JpegDirectory dir : metadata.getDirectoriesOfType(JpegDirectory.class)) {
if (dir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(JpegDirectory.TAG_IMAGE_WIDTH);
}
if (dir.containsTag(JpegDirectory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(JpegDirectory.TAG_IMAGE_HEIGHT);
}
}
break;
case MimeTypes.MP4:
for (Mp4VideoDirectory dir : metadata.getDirectoriesOfType(Mp4VideoDirectory.class)) {
if (dir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
width = dir.getInt(Mp4VideoDirectory.TAG_WIDTH);
}
if (dir.containsTag(Mp4VideoDirectory.TAG_HEIGHT)) {
height = dir.getInt(Mp4VideoDirectory.TAG_HEIGHT);
}
}
for (Mp4Directory dir : metadata.getDirectoriesOfType(Mp4Directory.class)) {
if (dir.containsTag(Mp4Directory.TAG_DURATION)) {
durationMillis = dir.getLong(Mp4Directory.TAG_DURATION);
}
}
break;
case MimeTypes.AVI:
for (AviDirectory dir : metadata.getDirectoriesOfType(AviDirectory.class)) {
if (dir.containsTag(AviDirectory.TAG_WIDTH)) {
width = dir.getInt(AviDirectory.TAG_WIDTH);
}
if (dir.containsTag(AviDirectory.TAG_HEIGHT)) {
height = dir.getInt(AviDirectory.TAG_HEIGHT);
}
if (dir.containsTag(AviDirectory.TAG_DURATION)) {
durationMillis = dir.getLong(AviDirectory.TAG_DURATION);
}
}
break;
case MimeTypes.PSD:
for (PsdHeaderDirectory dir : metadata.getDirectoriesOfType(PsdHeaderDirectory.class)) {
if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH);
}
if (dir.containsTag(PsdHeaderDirectory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT);
}
}
break;
}
for (ExifIFD0Directory dir : metadata.getDirectoriesOfType(ExifIFD0Directory.class)) {
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_WIDTH)) {
width = dir.getInt(ExifIFD0Directory.TAG_IMAGE_WIDTH);
}
if (dir.containsTag(ExifIFD0Directory.TAG_IMAGE_HEIGHT)) {
height = dir.getInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT);
}
if (dir.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
orientationDegrees = getOrientationDegreesForExifCode(dir.getInt(ExifIFD0Directory.TAG_ORIENTATION));
}
if (dir.containsTag(ExifIFD0Directory.TAG_DATETIME)) {
sourceDateTakenMillis = dir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
}
} catch (IOException | ImageProcessingException | MetadataException | NoClassDefFoundError e) {
// ignore
}
}
// expects entry with: uri
// finds: width, height
private void fillByBitmapDecode(@NonNull Context context) {
if (isSvg()) return;
try (InputStream is = StorageUtils.openInputStream(context, uri)) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeStream(is, null, options);
width = options.outWidth;
height = options.outHeight;
} catch (IOException e) {
// ignore
}
}
// convenience method
private static Long toLong(@Nullable Object o) {
if (o == null) return null;
if (o instanceof Integer) return Long.valueOf((Integer) o);
return (long) o;
}
}

View file

@ -10,12 +10,9 @@ import deckers.thibault.aves.model.SourceImageEntry;
class ContentImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
SourceImageEntry entry = new SourceImageEntry();
entry.uri = uri;
entry.sourceMimeType = mimeType;
entry.fillPreCatalogMetadata(context);
SourceImageEntry entry = new SourceImageEntry(uri, mimeType).fillPreCatalogMetadata(context);
if (entry.hasSize() || entry.isSvg()) {
if (entry.getHasSize() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));

View file

@ -13,19 +13,14 @@ import deckers.thibault.aves.utils.FileUtils;
class FileImageProvider extends ImageProvider {
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
SourceImageEntry entry = new SourceImageEntry();
entry.uri = uri;
entry.sourceMimeType = mimeType;
SourceImageEntry entry = new SourceImageEntry(uri, mimeType);
String path = FileUtils.getPathFromUri(context, uri);
if (path != null) {
try {
File file = new File(path);
if (file.exists()) {
entry.path = path;
entry.title = file.getName();
entry.sizeBytes = file.length();
entry.dateModifiedSecs = file.lastModified() / 1000;
entry.initFromFile(path, file.getName(), file.length(), file.lastModified() / 1000);
}
} catch (SecurityException e) {
callback.onFailure(e);
@ -33,7 +28,7 @@ class FileImageProvider extends ImageProvider {
}
entry.fillPreCatalogMetadata(context);
if (entry.hasSize() || entry.isSvg()) {
if (entry.getHasSize() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));

View file

@ -3,8 +3,6 @@ package deckers.thibault.aves.model.provider;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.provider.MediaStore;
@ -13,21 +11,18 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.exifinterface.media.ExifInterface;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
@ -86,21 +81,24 @@ public abstract class ImageProvider {
scanNewPath(context, newFile.getPath(), mimeType, callback);
}
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0
private boolean canEditExif(@NonNull String mimeType) {
switch (mimeType) {
case MimeTypes.JPEG:
rotateJpeg(context, path, uri, clockwise, callback);
break;
case MimeTypes.PNG:
rotatePng(context, path, uri, clockwise, callback);
break;
case "image/jpeg":
case "image/png":
case "image/webp":
return true;
default:
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
return false;
}
}
private void rotateJpeg(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
final String mimeType = MimeTypes.JPEG;
public void rotate(final Context context, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) {
if (!canEditExif(mimeType)) {
callback.onFailure(new UnsupportedOperationException("unsupported mimeType=" + mimeType));
return;
}
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
if (originalDocumentFile == null) {
@ -115,38 +113,30 @@ public abstract class ImageProvider {
return;
}
int newOrientationCode;
Map<String, Object> newFields = new HashMap<>();
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;
// when the orientation is not defined, it returns `undefined (0)` instead of the orientation default value `normal (1)`
// in that case we explicitely set it to `normal` first
// because ExifInterface fails to rotate an image with undefined orientation
// as of androidx.exifinterface:exifinterface:1.3.0
int currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(ExifInterface.ORIENTATION_NORMAL));
}
exif.setAttribute(ExifInterface.TAG_ORIENTATION, Integer.toString(newOrientationCode));
exif.rotate(clockwise ? 90 : -90);
exif.saveAttributes();
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile);
newFields.put("rotationDegrees", exif.getRotationDegrees());
newFields.put("isFlipped", exif.isFlipped());
} catch (IOException e) {
callback.onFailure(e);
return;
}
// update fields in media store
int orientationDegrees = MetadataHelper.getOrientationDegreesForExifCode(newOrientationCode);
Map<String, Object> newFields = new HashMap<>();
newFields.put("orientationDegrees", orientationDegrees);
// ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first
@ -158,75 +148,7 @@ public abstract class ImageProvider {
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
// }
// // uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
// values.put(MediaStore.Images.Media.ORIENTATION, orientationDegrees);
// // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) {
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> callback.onSuccess(newFields));
// } else {
// Log.w(LOG_TAG, "failed to update fields in Media Store for uri=" + uri);
// callback.onSuccess(newFields);
// }
}
private void rotatePng(final Context context, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
final String mimeType = MimeTypes.PNG;
final DocumentFileCompat originalDocumentFile = StorageUtils.getDocumentFile(context, path, uri);
if (originalDocumentFile == null) {
callback.onFailure(new Exception("failed to get document file for path=" + path + ", uri=" + uri));
return;
}
// copy original file to a temporary file for editing
final String editablePath = StorageUtils.copyFileToTemp(originalDocumentFile, path);
if (editablePath == null) {
callback.onFailure(new Exception("failed to create a temporary file for path=" + path));
return;
}
Bitmap originalImage;
try {
originalImage = BitmapFactory.decodeStream(StorageUtils.openInputStream(context, uri));
} catch (FileNotFoundException e) {
callback.onFailure(e);
return;
}
if (originalImage == null) {
callback.onFailure(new Exception("failed to decode image at path=" + path));
return;
}
Bitmap rotatedImage = TransformationUtils.rotateImage(originalImage, clockwise ? 90 : -90);
try (FileOutputStream fos = new FileOutputStream(editablePath)) {
rotatedImage.compress(Bitmap.CompressFormat.PNG, 100, fos);
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(new File(editablePath)).copyTo(originalDocumentFile);
} catch (IOException e) {
callback.onFailure(e);
return;
}
// update fields in media store
int rotatedWidth = originalImage.getHeight();
int rotatedHeight = originalImage.getWidth();
Map<String, Object> newFields = new HashMap<>();
newFields.put("width", rotatedWidth);
newFields.put("height", rotatedHeight);
// ContentResolver contentResolver = context.getContentResolver();
// ContentValues values = new ContentValues();
// // from Android Q, media store update needs to be flagged IS_PENDING first
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// values.put(MediaStore.MediaColumns.IS_PENDING, 1);
// // TODO TLAD catch RecoverableSecurityException
// contentResolver.update(uri, values, null, null);
// values.clear();
// values.put(MediaStore.MediaColumns.IS_PENDING, 0);
// }
// values.put(MediaStore.MediaColumns.WIDTH, rotatedWidth);
// values.put(MediaStore.MediaColumns.HEIGHT, rotatedHeight);
// values.put(MediaStore.Images.Media.ORIENTATION, rotationDegrees);
// // TODO TLAD catch RecoverableSecurityException
// int updatedRowCount = contentResolver.update(uri, values, null, null);
// if (updatedRowCount > 0) {
@ -245,9 +167,9 @@ public abstract class ImageProvider {
// newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872")
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872")
contentId = ContentUris.parseId(newUri);
if (mimeType.startsWith(MimeTypes.IMAGE)) {
if (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
} else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
}
}

View file

@ -85,10 +85,10 @@ public class MediaStoreImageProvider extends ImageProvider {
callback.onSuccess(entry);
};
NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true;
if (mimeType.startsWith(MimeTypes.IMAGE)) {
if (MimeTypes.isImage(mimeType)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION);
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
} else if (MimeTypes.isVideo(mimeType)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION);
}
@ -124,9 +124,10 @@ public class MediaStoreImageProvider extends ImageProvider {
@SuppressLint("InlinedApi")
private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) {
int newEntryCount = 0;
final boolean needDuration = projection == VIDEO_PROJECTION;
final String orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC";
final boolean needDuration = projection == VIDEO_PROJECTION;
try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, orderBy);
if (cursor != null) {
@ -159,11 +160,15 @@ public class MediaStoreImageProvider extends ImageProvider {
int height = cursor.getInt(heightColumn);
final long durationMillis = durationColumn != -1 ? cursor.getLong(durationColumn) : 0;
// check whether the field may be `null` to distinguish it from a legitimate `0`
// this can happen for specific formats (e.g. for PNG, WEBP)
// or for JPEG that were not properly registered
Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString());
put("path", path);
put("sourceMimeType", mimeType);
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sourceRotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn));
put("dateModifiedSecs", dateModifiedSecs);
@ -175,7 +180,8 @@ public class MediaStoreImageProvider extends ImageProvider {
entryMap.put("height", height);
entryMap.put("durationMillis", durationMillis);
if (((width <= 0 || height <= 0) && needSize(mimeType)) || (durationMillis == 0 && needDuration)) {
if (((width <= 0 || height <= 0) && needSize(mimeType))
|| (durationMillis == 0 && needDuration)) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
SourceImageEntry entry = new SourceImageEntry(entryMap).fillPreCatalogMetadata(context);
@ -316,7 +322,7 @@ public class MediaStoreImageProvider extends ImageProvider {
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
String volumeName = destination.volumeNameForMediaStore;
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ?
Uri tableUrl = MimeTypes.isVideo(mimeType) ?
MediaStore.Video.Media.getContentUri(volumeName) :
MediaStore.Images.Media.getContentUri(volumeName);
Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues);

View file

@ -0,0 +1,590 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader
import com.drew.imaging.ImageProcessingException
import com.drew.lang.Rational
import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.exif.ExifSubIFDDirectory
import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.webp.WebpDirectory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.ExifInterfaceHelper.describeAll
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.Metadata.isFlippedForExifCode
import deckers.thibault.aves.utils.Metadata.parseVideoMetadataDate
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDescription
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.utils.MimeTypes.getMimeTypeForExtension
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.XMP.getSafeLocalizedText
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.io.IOException
import java.util.*
import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAllMetadata" -> Thread { getAllMetadata(call, MethodResultWrapper(result)) }.start()
"getCatalogMetadata" -> Thread { getCatalogMetadata(call, MethodResultWrapper(result)) }.start()
"getOverlayMetadata" -> Thread { getOverlayMetadata(call, MethodResultWrapper(result)) }.start()
"getContentResolverMetadata" -> Thread { getContentResolverMetadata(call, MethodResultWrapper(result)) }.start()
"getExifInterfaceMetadata" -> Thread { getExifInterfaceMetadata(call, MethodResultWrapper(result)) }.start()
"getMediaMetadataRetrieverMetadata" -> Thread { getMediaMetadataRetrieverMetadata(call, MethodResultWrapper(result)) }.start()
"getEmbeddedPictures" -> Thread { getEmbeddedPictures(call, MethodResultWrapper(result)) }.start()
"getExifThumbnails" -> Thread { getExifThumbnails(call, MethodResultWrapper(result)) }.start()
"getXmpThumbnails" -> Thread { getXmpThumbnails(call, MethodResultWrapper(result)) }.start()
else -> result.notImplemented()
}
}
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, MutableMap<String, String>>()
var foundExif = false
var foundXmp = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
for (dir in metadata.directories.filter { it.tagCount > 0 && it !is FileTypeDirectory }) {
// directory name
val dirName = dir.name ?: ""
val dirMap = metadataMap.getOrDefault(dirName, HashMap())
metadataMap[dirName] = dirMap
// tags
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
if (dir is XmpDirectory) {
try {
val xmpMeta = dir.xmpMeta.apply { sort() }
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) {
val path = prop.path
val value = prop.value
if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
}
}
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
val allTags = describeAll(exif).toMutableMap()
if (foundXmp) {
// do not overwrite XMP parsed by metadata-extractor
// with raw XMP found by ExifInterface
allTags.remove(Metadata.DIR_XMP)
}
metadataMap.putAll(allTags.mapValues { it.value.toMutableMap() })
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
val mediaDir = getAllMetadataByMediaMetadataRetriever(uri)
if (mediaDir.isNotEmpty()) {
metadataMap[Metadata.DIR_MEDIA] = mediaDir
}
if (metadataMap.isNotEmpty()) {
result.success(metadataMap)
} else {
result.error("getAllMetadata-failure", "failed to get metadata for uri=$uri", null)
}
}
private fun getAllMetadataByMediaMetadataRetriever(uri: Uri): MutableMap<String, String> {
val dirMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return dirMap
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.getSafeDescription(code, context) { dirMap[name] = it }
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get video metadata by MediaMetadataRetriever for uri=$uri", e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
return dirMap
}
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
val extension = call.argument<String>("extension")
if (mimeType == null || uri == null) {
result.error("getCatalogMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap(getCatalogMetadataByMetadataExtractor(uri, mimeType, extension))
if (isVideo(mimeType)) {
metadataMap.putAll(getVideoCatalogMetadataByMediaMetadataRetriever(uri))
}
// report success even when empty
result.success(metadataMap)
}
private fun getCatalogMetadataByMetadataExtractor(uri: Uri, mimeType: String, extension: String?): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
var foundExif = false
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// `metadata-extractor` sometimes detect the the wrong mime type (e.g. `pef` file as `tiff`)
// the content resolver / media store sometimes report the wrong mime type (e.g. `png` file as `jpeg`)
// `context.getContentResolver().getType()` sometimes return incorrect value
// `MediaMetadataRetriever.setDataSource()` sometimes fail with `status = 0x80000000`
if (dir.containsTag(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)) {
val detectedMimeType = dir.getString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE)
if (detectedMimeType != null && detectedMimeType != mimeType) {
// file extension is unreliable, but we use it as a tie breaker
val extensionMimeType = extension?.toLowerCase(Locale.ROOT)?.let { getMimeTypeForExtension(it) }
if (extensionMimeType == null || detectedMimeType == extensionMimeType) {
metadataMap[KEY_MIME_TYPE] = detectedMimeType
}
}
}
}
// EXIF
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDateMillis(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
}
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
}
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) {
val orientation = it
metadataMap[KEY_IS_FLIPPED] = isFlippedForExifCode(orientation)
metadataMap[KEY_ROTATION_DEGREES] = getRotationDegreesForExifCode(orientation)
}
}
// GPS
for (dir in metadata.getDirectoriesOfType(GpsDirectory::class.java)) {
val geoLocation = dir.geoLocation
if (geoLocation != null) {
metadataMap[KEY_LATITUDE] = geoLocation.latitude
metadataMap[KEY_LONGITUDE] = geoLocation.longitude
}
}
// XMP
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = ";")
}
xmpMeta.getSafeLocalizedText(XMP.TITLE_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
xmpMeta.getSafeLocalizedText(XMP.DESCRIPTION_PROP_NAME) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
// Animated GIF & WEBP
when (mimeType) {
MimeTypes.GIF -> {
metadataMap[KEY_IS_ANIMATED] = metadata.containsDirectoryOfType(GifAnimationDirectory::class.java)
}
MimeTypes.WEBP -> {
for (dir in metadata.getDirectoriesOfType(WebpDirectory::class.java)) {
dir.getSafeBoolean(WebpDirectory.TAG_IS_ANIMATION) { metadataMap[KEY_IS_ANIMATED] = it }
}
}
else -> {
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to get catalog metadata by metadata-extractor for uri=$uri, mimeType=$mimeType", e)
}
}
if (!foundExif) {
// fallback to read EXIF via ExifInterface
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME_ORIGINAL) { metadataMap[KEY_DATE_MILLIS] = it }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { metadataMap[KEY_DATE_MILLIS] = it }
}
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) {
metadataMap[KEY_IS_FLIPPED] = exif.isFlipped
metadataMap[KEY_ROTATION_DEGREES] = exif.rotationDegrees
}
val latLong = exif.latLong
if (latLong != null && latLong.size == 2) {
metadataMap[KEY_LATITUDE] = latLong[0]
metadataMap[KEY_LONGITUDE] = latLong[1]
}
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
Log.w(LOG_TAG, "failed to get metadata by ExifInterface for uri=$uri", e)
}
}
return metadataMap
}
private fun getVideoCatalogMetadataByMediaMetadataRetriever(uri: Uri): Map<String, Any> {
val metadataMap = HashMap<String, Any>()
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return metadataMap
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
val dateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE)
if (dateString != null) {
val dateMillis = parseVideoMetadataDate(dateString)
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) {
metadataMap[KEY_DATE_MILLIS] = dateMillis
}
}
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) {
val locationMatcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (locationMatcher.find() && locationMatcher.groupCount() >= 2) {
val latitudeString = locationMatcher.group(1)
val longitudeString = locationMatcher.group(2)
if (latitudeString != null && longitudeString != null) {
try {
val latitude = latitudeString.toDoubleOrNull() ?: 0
val longitude = longitudeString.toDoubleOrNull() ?: 0
// keep `0.0` as `0.0`, not `0`
if (latitude != 0.0 || longitude != 0.0) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
} catch (e: NumberFormatException) {
// ignore
}
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get catalog metadata by MediaMetadataRetriever for uri=$uri", e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
return metadataMap
}
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getOverlayMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, Any>()
if (isVideo(mimeType) || !isSupportedByMetadataExtractor(mimeType)) {
result.success(metadataMap)
return
}
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(ExifSubIFDDirectory::class.java)) {
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FNUMBER) { metadataMap[KEY_APERTURE] = it }
dir.getSafeDescription(ExifSubIFDDirectory.TAG_FOCAL_LENGTH) { metadataMap[KEY_FOCAL_LENGTH] = it }
dir.getSafeDescription(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT) { metadataMap[KEY_ISO] = "ISO$it" }
dir.getSafeRational(ExifSubIFDDirectory.TAG_EXPOSURE_TIME) {
// TAG_EXPOSURE_TIME as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
val num = it.numerator
val denom = it.denominator
metadataMap[KEY_EXPOSURE_TIME] = when {
num >= denom -> it.toSimpleString(true) + ""
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
else -> it.toString()
}
}
}
result.success(metadataMap)
}
} catch (e: Exception) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
} catch (e: NoClassDefFoundError) {
result.error("getOverlayMetadata-exception", "failed to get metadata for uri=$uri", e.message)
}
}
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "failed because of missing arguments", null)
return
}
val id = ContentUris.parseId(uri)
var contentUri = when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentUri = MediaStore.setRequireOriginal(contentUri)
}
val cursor = context.contentResolver.query(contentUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val metadataMap = HashMap<String, Any?>()
val columnCount = cursor.columnCount
val columnNames = cursor.columnNames
for (i in 0 until columnCount) {
val key = columnNames[i]
try {
metadataMap[key] = when (cursor.getType(i)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i)
else -> null
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get value for key=$key", e)
}
}
cursor.close()
result.success(metadataMap)
} else {
result.error("getContentResolverMetadata-null", "failed to get cursor for contentUri=$contentUri", null)
}
}
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null)
return
}
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
val metadataMap = HashMap<String, String?>()
for (tag in ExifInterfaceHelper.allTags.keys.filter { exif.hasAttribute(it) }) {
metadataMap[tag] = exif.getAttribute(tag)
}
result.success(metadataMap)
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=$uri", e.message)
}
}
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "failed because of missing arguments", null)
return
}
val metadataMap = HashMap<String, String>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
for ((code, name) in MediaMetadataRetrieverHelper.allKeys) {
retriever.extractMetadata(code)?.let { metadataMap[name] = it }
}
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(metadataMap)
}
private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getEmbeddedPictures-args", "failed because of missing arguments", null)
return
}
val pictures = ArrayList<ByteArray>()
val retriever = StorageUtils.openMetadataRetriever(context, uri)
if (retriever != null) {
try {
retriever.embeddedPicture?.let { pictures.add(it) }
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
result.success(pictures)
}
private fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.parse(call.argument("uri"))
if (uri == null) {
result.error("getExifThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
exif.thumbnailBytes?.let { thumbnails.add(it) }
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
result.success(thumbnails)
}
private fun getXmpThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = Uri.parse(call.argument("uri"))
if (mimeType == null || uri == null) {
result.error("getXmpThumbnails-args", "failed because of missing arguments", null)
return
}
val thumbnails = ArrayList<ByteArray>()
if (isSupportedByMetadataExtractor(mimeType)) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
val xmpMeta = dir.xmpMeta
try {
if (xmpMeta.doesPropertyExist(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.XMP_SCHEMA_NS, XMP.THUMBNAIL_PROP_NAME)
for (i in 1 until count + 1) {
val structName = "${XMP.THUMBNAIL_PROP_NAME}[$i]"
val image = xmpMeta.getStructField(XMP.XMP_SCHEMA_NS, structName, XMP.IMG_SCHEMA_NS, XMP.THUMBNAIL_IMAGE_PROP_NAME)
if (image != null) {
thumbnails.add(XMPUtils.decodeBase64(image.value))
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
}
}
} catch (e: IOException) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
} catch (e: ImageProcessingException) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
} catch (e: NoClassDefFoundError) {
Log.w(LOG_TAG, "failed to extract xmp thumbnail", e)
}
}
result.success(thumbnails)
}
companion object {
private val LOG_TAG = Utils.createLogTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata"
// catalog metadata
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
private const val KEY_IS_ANIMATED = "isAnimated"
private const val KEY_IS_FLIPPED = "isFlipped"
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
private const val KEY_LATITUDE = "latitude"
private const val KEY_LONGITUDE = "longitude"
private const val KEY_XMP_SUBJECTS = "xmpSubjects"
private const val KEY_XMP_TITLE_DESCRIPTION = "xmpTitleDescription"
// overlay metadata
private const val KEY_APERTURE = "aperture"
private const val KEY_EXPOSURE_TIME = "exposureTime"
private const val KEY_FOCAL_LENGTH = "focalLength"
private const val KEY_ISO = "iso"
}
}

View file

@ -0,0 +1,38 @@
package deckers.thibault.aves.model
import android.net.Uri
import deckers.thibault.aves.utils.MimeTypes
class AvesImageEntry(map: Map<String?, Any?>) {
@JvmField
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
@JvmField
val path = map["path"] as String? // best effort to get local path
@JvmField
val mimeType = map["mimeType"] as String
@JvmField
val width = map["width"] as Int
@JvmField
val height = map["height"] as Int
@JvmField
val rotationDegrees = map["rotationDegrees"] as Int
@JvmField
val dateModifiedSecs = toLong(map["dateModifiedSecs"])
val isVideo: Boolean
get() = MimeTypes.isVideo(mimeType)
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {
is Int -> o.toLong()
else -> o as? Long
}
}
}

View file

@ -0,0 +1,248 @@
package deckers.thibault.aves.model
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.avi.AviDirectory
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.jpeg.JpegDirectory
import com.drew.metadata.mp4.Mp4Directory
import com.drew.metadata.mp4.media.Mp4VideoDirectory
import com.drew.metadata.photoshop.PsdHeaderDirectory
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.utils.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.utils.MediaMetadataRetrieverHelper.getSafeString
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.utils.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.utils.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException
class SourceImageEntry {
val uri: Uri // content or file URI
var path: String? = null // best effort to get local path
private val sourceMimeType: String
private var title: String? = null
var width: Int? = null
var height: Int? = null
private var sourceRotationDegrees: Int? = null
private var sizeBytes: Long? = null
private var dateModifiedSecs: Long? = null
private var sourceDateTakenMillis: Long? = null
private var durationMillis: Long? = null
private var foundExif: Boolean = false
constructor(uri: Uri, sourceMimeType: String) {
this.uri = uri
this.sourceMimeType = sourceMimeType
}
constructor(map: Map<String, Any?>) {
uri = Uri.parse(map["uri"] as String)
path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String
width = map["width"] as Int?
height = map["height"] as Int?
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
sizeBytes = toLong(map["sizeBytes"])
title = map["title"] as String?
dateModifiedSecs = toLong(map["dateModifiedSecs"])
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"])
durationMillis = toLong(map["durationMillis"])
}
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
this.path = path
this.title = title
this.sizeBytes = sizeBytes
this.dateModifiedSecs = dateModifiedSecs
}
fun toMap(): Map<String, Any?> {
return hashMapOf(
"uri" to uri.toString(),
"path" to path,
"sourceMimeType" to sourceMimeType,
"width" to width,
"height" to height,
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
"sizeBytes" to sizeBytes,
"title" to title,
"dateModifiedSecs" to dateModifiedSecs,
"sourceDateTakenMillis" to sourceDateTakenMillis,
"durationMillis" to durationMillis,
// only for map export
"contentId" to contentId,
)
}
// ignore when the ID is not a number
// e.g. content://com.sec.android.app.myfiles.FileProvider/device_storage/20200109_162621.jpg
private val contentId: Long?
get() {
if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
try {
return ContentUris.parseId(uri)
} catch (e: Exception) {
// ignore
}
}
return null
}
val hasSize: Boolean
get() = width ?: 0 > 0 && height ?: 0 > 0
private val hasDuration: Boolean
get() = durationMillis ?: 0 > 0
private val isImage: Boolean
get() = MimeTypes.isImage(sourceMimeType)
private val isVideo: Boolean
get() = MimeTypes.isVideo(sourceMimeType)
val isSvg: Boolean
get() = sourceMimeType == MimeTypes.SVG
// metadata retrieval
// expects entry with: uri, mimeType
// finds: width, height, orientation/rotation, date, title, duration
fun fillPreCatalogMetadata(context: Context): SourceImageEntry {
if (isSvg) return this
if (isVideo) {
fillVideoByMediaMetadataRetriever(context)
if (hasSize && hasDuration) return this
}
if (MimeTypes.isSupportedByMetadataExtractor(sourceMimeType)) {
fillByMetadataExtractor(context)
if (hasSize && foundExif) return this
}
if (ExifInterface.isSupportedMimeType(sourceMimeType)) {
fillByExifInterface(context)
if (hasSize) return this
}
fillByBitmapDecode(context)
return this
}
// finds: width, height, orientation, date, duration, title
private fun fillVideoByMediaMetadataRetriever(context: Context) {
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
} catch (e: Exception) {
// ignore
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
// finds: width, height, orientation, date, duration
private fun fillByMetadataExtractor(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val metadata = ImageMetadataReader.readMetadata(input)
// do not switch on specific mime types, as the reported mime type could be wrong
// (e.g. PNG registered as JPG)
if (isVideo) {
for (dir in metadata.getDirectoriesOfType(AviDirectory::class.java)) {
dir.getSafeInt(AviDirectory.TAG_WIDTH) { width = it }
dir.getSafeInt(AviDirectory.TAG_HEIGHT) { height = it }
dir.getSafeLong(AviDirectory.TAG_DURATION) { durationMillis = it }
}
for (dir in metadata.getDirectoriesOfType(Mp4VideoDirectory::class.java)) {
dir.getSafeInt(Mp4VideoDirectory.TAG_WIDTH) { width = it }
dir.getSafeInt(Mp4VideoDirectory.TAG_HEIGHT) { height = it }
}
for (dir in metadata.getDirectoriesOfType(Mp4Directory::class.java)) {
dir.getSafeInt(Mp4Directory.TAG_ROTATION) { sourceRotationDegrees = it }
dir.getSafeLong(Mp4Directory.TAG_DURATION) { durationMillis = it }
}
} else {
// EXIF, if defined, should override metadata found in other directories
for (dir in metadata.getDirectoriesOfType(ExifIFD0Directory::class.java)) {
foundExif = true
dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(ExifIFD0Directory.TAG_IMAGE_HEIGHT) { height = it }
dir.getSafeInt(ExifIFD0Directory.TAG_ORIENTATION) { sourceRotationDegrees = getRotationDegreesForExifCode(it) }
dir.getSafeDateMillis(ExifIFD0Directory.TAG_DATETIME) { sourceDateTakenMillis = it }
}
if (!foundExif) {
for (dir in metadata.getDirectoriesOfType(JpegDirectory::class.java)) {
dir.getSafeInt(JpegDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(JpegDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
for (dir in metadata.getDirectoriesOfType(PsdHeaderDirectory::class.java)) {
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_WIDTH) { width = it }
dir.getSafeInt(PsdHeaderDirectory.TAG_IMAGE_HEIGHT) { height = it }
}
}
}
}
} catch (e: Exception) {
// ignore
} catch (e: NoClassDefFoundError) {
// ignore
}
}
// finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val exif = ExifInterface(input)
foundExif = true
exif.getSafeInt(ExifInterface.TAG_IMAGE_WIDTH, acceptZero = false) { width = it }
exif.getSafeInt(ExifInterface.TAG_IMAGE_LENGTH, acceptZero = false) { height = it }
exif.getSafeInt(ExifInterface.TAG_ORIENTATION, acceptZero = false) { sourceRotationDegrees = exif.rotationDegrees }
exif.getSafeDateMillis(ExifInterface.TAG_DATETIME) { sourceDateTakenMillis = it }
}
} catch (e: Exception) {
// ExifInterface initialization can fail with a RuntimeException
// caused by an internal MediaMetadataRetriever failure
}
}
// finds: width, height
private fun fillByBitmapDecode(context: Context) {
try {
StorageUtils.openInputStream(context, uri).use { input ->
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, options)
width = options.outWidth
height = options.outHeight
}
} catch (e: IOException) {
// ignore
}
}
companion object {
// convenience method
private fun toLong(o: Any?): Long? = when (o) {
is Int -> o.toLong()
else -> o as? Long
}
}
}

View file

@ -0,0 +1,365 @@
package deckers.thibault.aves.utils
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.drew.lang.Rational
import com.drew.metadata.Directory
import com.drew.metadata.exif.*
import com.drew.metadata.exif.makernotes.OlympusCameraSettingsMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusImageProcessingMakernoteDirectory
import com.drew.metadata.exif.makernotes.OlympusMakernoteDirectory
import java.util.*
import kotlin.math.floor
import kotlin.math.roundToLong
object ExifInterfaceHelper {
private val LOG_TAG = Utils.createLogTag(ExifInterfaceHelper::class.java)
// ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing
private val neverNullTags = listOf(
ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_LIGHT_SOURCE,
ExifInterface.TAG_ORIENTATION,
)
private val baseTags: Map<String, TagMapper?> = hashMapOf(
ExifInterface.TAG_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_ARTIST to TagMapper(ExifDirectoryBase.TAG_ARTIST, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_BODY_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_BODY_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_BRIGHTNESS_VALUE to TagMapper(ExifDirectoryBase.TAG_BRIGHTNESS_VALUE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_CAMERA_OWNER_NAME to TagMapper(ExifDirectoryBase.TAG_CAMERA_OWNER_NAME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_CFA_PATTERN to TagMapper(ExifDirectoryBase.TAG_CFA_PATTERN, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_COLOR_SPACE to TagMapper(ExifDirectoryBase.TAG_COLOR_SPACE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_COMPONENTS_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_COMPONENTS_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_COMPRESSED_AVERAGE_BITS_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_COMPRESSION to TagMapper(ExifDirectoryBase.TAG_COMPRESSION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_CONTRAST to TagMapper(ExifDirectoryBase.TAG_CONTRAST, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_COPYRIGHT to TagMapper(ExifDirectoryBase.TAG_COPYRIGHT, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_CUSTOM_RENDERED to TagMapper(ExifDirectoryBase.TAG_CUSTOM_RENDERED, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_DATETIME to TagMapper(ExifDirectoryBase.TAG_DATETIME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DATETIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_DATETIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DATETIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_DATETIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_DEVICE_SETTING_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_DIGITAL_ZOOM_RATIO to TagMapper(ExifDirectoryBase.TAG_DIGITAL_ZOOM_RATIO, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXIF_VERSION to TagMapper(ExifDirectoryBase.TAG_EXIF_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_EXPOSURE_BIAS_VALUE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_BIAS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_MODE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_EXPOSURE_PROGRAM to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_PROGRAM, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_EXPOSURE_TIME to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_TIME, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FILE_SOURCE to TagMapper(ExifDirectoryBase.TAG_FILE_SOURCE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_FLASH to TagMapper(ExifDirectoryBase.TAG_FLASH, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FLASHPIX_VERSION to TagMapper(ExifDirectoryBase.TAG_FLASHPIX_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_FLASH_ENERGY to TagMapper(ExifDirectoryBase.TAG_FLASH_ENERGY, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_LENGTH to TagMapper(ExifDirectoryBase.TAG_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM to TagMapper(ExifDirectoryBase.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_F_NUMBER to TagMapper(ExifDirectoryBase.TAG_FNUMBER, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_GAIN_CONTROL to TagMapper(ExifDirectoryBase.TAG_GAIN_CONTROL, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_GAMMA to TagMapper(ExifDirectoryBase.TAG_GAMMA, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_IMAGE_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_IMAGE_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_IMAGE_UNIQUE_ID to TagMapper(ExifDirectoryBase.TAG_IMAGE_UNIQUE_ID, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_INTEROPERABILITY_INDEX to TagMapper(ExifDirectoryBase.TAG_INTEROP_INDEX, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ISO_SPEED to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_YYY, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_ZZZ, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_LENS_MAKE to TagMapper(ExifDirectoryBase.TAG_LENS_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_MODEL to TagMapper(ExifDirectoryBase.TAG_LENS_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_LENS_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_SPECIFICATION to TagMapper(ExifDirectoryBase.TAG_LENS_SPECIFICATION, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_LIGHT_SOURCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_MAKE to TagMapper(ExifDirectoryBase.TAG_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_MAKER_NOTE to TagMapper(ExifDirectoryBase.TAG_MAKERNOTE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_MAX_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_MAX_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_METERING_MODE to TagMapper(ExifDirectoryBase.TAG_METERING_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_MODEL to TagMapper(ExifDirectoryBase.TAG_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_NEW_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_NEW_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_OECF to TagMapper(ExifDirectoryBase.TAG_OPTO_ELECTRIC_CONVERSION_FUNCTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_OFFSET_TIME to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_OFFSET_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_OFFSET_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ORIENTATION to TagMapper(ExifDirectoryBase.TAG_ORIENTATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_ISO_EQUIVALENT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION to TagMapper(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PIXEL_X_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_PIXEL_Y_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_PLANAR_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_PLANAR_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PRIMARY_CHROMATICITIES to TagMapper(ExifDirectoryBase.TAG_PRIMARY_CHROMATICITIES, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_RECOMMENDED_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_REFERENCE_BLACK_WHITE to TagMapper(ExifDirectoryBase.TAG_REFERENCE_BLACK_WHITE, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_RELATED_SOUND_FILE to TagMapper(ExifDirectoryBase.TAG_RELATED_SOUND_FILE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_ROWS_PER_STRIP to TagMapper(ExifDirectoryBase.TAG_ROWS_PER_STRIP, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SAMPLES_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_SAMPLES_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SATURATION to TagMapper(ExifDirectoryBase.TAG_SATURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SCENE_CAPTURE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_CAPTURE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SCENE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_TYPE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_SENSING_METHOD to TagMapper(ExifDirectoryBase.TAG_SENSING_METHOD, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SENSITIVITY_TYPE to TagMapper(ExifDirectoryBase.TAG_SENSITIVITY_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SHARPNESS to TagMapper(ExifDirectoryBase.TAG_SHARPNESS, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SHUTTER_SPEED_VALUE to TagMapper(ExifDirectoryBase.TAG_SHUTTER_SPEED, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_SOFTWARE to TagMapper(ExifDirectoryBase.TAG_SOFTWARE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE to TagMapper(ExifDirectoryBase.TAG_SPATIAL_FREQ_RESPONSE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_SPECTRAL_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_SPECTRAL_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_STANDARD_OUTPUT_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_STRIP_BYTE_COUNTS to TagMapper(ExifDirectoryBase.TAG_STRIP_BYTE_COUNTS, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_STRIP_OFFSETS to TagMapper(ExifDirectoryBase.TAG_STRIP_OFFSETS, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_AREA to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION_TIFF_EP, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_DISTANCE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE_RANGE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_LOCATION to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBSEC_TIME to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0101
ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0100
ExifInterface.TAG_TRANSFER_FUNCTION to TagMapper(ExifDirectoryBase.TAG_TRANSFER_FUNCTION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_USER_COMMENT to TagMapper(ExifDirectoryBase.TAG_USER_COMMENT, DirType.EXIF_IFD0, TagFormat.COMMENT),
ExifInterface.TAG_WHITE_BALANCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_WHITE_POINT to TagMapper(ExifDirectoryBase.TAG_WHITE_POINT, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_CB_CR_COEFFICIENTS to TagMapper(ExifDirectoryBase.TAG_YCBCR_COEFFICIENTS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_CB_CR_POSITIONING to TagMapper(ExifDirectoryBase.TAG_YCBCR_POSITIONING, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING to TagMapper(ExifDirectoryBase.TAG_YCBCR_SUBSAMPLING, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
)
private val thumbnailTags: Map<String, TagMapper?> = hashMapOf(
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0201
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0202
)
private val gpsTags: Map<String, TagMapper?> = hashMapOf(
ExifInterface.TAG_GPS_ALTITUDE to TagMapper(GpsDirectory.TAG_ALTITUDE, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_ALTITUDE_REF to TagMapper(GpsDirectory.TAG_ALTITUDE_REF, DirType.GPS, TagFormat.BYTE),
ExifInterface.TAG_GPS_AREA_INFORMATION to TagMapper(GpsDirectory.TAG_AREA_INFORMATION, DirType.GPS, TagFormat.COMMENT),
ExifInterface.TAG_GPS_DATESTAMP to TagMapper(GpsDirectory.TAG_DATE_STAMP, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_BEARING to TagMapper(GpsDirectory.TAG_DEST_BEARING, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_DEST_BEARING_REF to TagMapper(GpsDirectory.TAG_DEST_BEARING_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_DISTANCE to TagMapper(GpsDirectory.TAG_DEST_DISTANCE, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_DEST_DISTANCE_REF to TagMapper(GpsDirectory.TAG_DEST_DISTANCE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_LATITUDE to TagMapper(GpsDirectory.TAG_DEST_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_DEST_LATITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DEST_LONGITUDE to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_DIFFERENTIAL to TagMapper(GpsDirectory.TAG_DIFFERENTIAL, DirType.GPS, TagFormat.SHORT),
ExifInterface.TAG_GPS_DOP to TagMapper(GpsDirectory.TAG_DOP, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_H_POSITIONING_ERROR to TagMapper(GpsDirectory.TAG_H_POSITIONING_ERROR, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_IMG_DIRECTION to TagMapper(GpsDirectory.TAG_IMG_DIRECTION, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_IMG_DIRECTION_REF to TagMapper(GpsDirectory.TAG_IMG_DIRECTION_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_LATITUDE to TagMapper(GpsDirectory.TAG_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_LATITUDE_REF to TagMapper(GpsDirectory.TAG_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_LONGITUDE to TagMapper(GpsDirectory.TAG_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_MAP_DATUM to TagMapper(GpsDirectory.TAG_MAP_DATUM, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_MEASURE_MODE to TagMapper(GpsDirectory.TAG_MEASURE_MODE, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_PROCESSING_METHOD to TagMapper(GpsDirectory.TAG_PROCESSING_METHOD, DirType.GPS, TagFormat.COMMENT),
ExifInterface.TAG_GPS_SATELLITES to TagMapper(GpsDirectory.TAG_SATELLITES, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_SPEED to TagMapper(GpsDirectory.TAG_SPEED, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_SPEED_REF to TagMapper(GpsDirectory.TAG_SPEED_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_STATUS to TagMapper(GpsDirectory.TAG_STATUS, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_TIMESTAMP to TagMapper(GpsDirectory.TAG_TIME_STAMP, DirType.GPS, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_GPS_TRACK to TagMapper(GpsDirectory.TAG_TRACK, DirType.GPS, TagFormat.RATIONAL),
ExifInterface.TAG_GPS_TRACK_REF to TagMapper(GpsDirectory.TAG_TRACK_REF, DirType.GPS, TagFormat.ASCII),
ExifInterface.TAG_GPS_VERSION_ID to TagMapper(GpsDirectory.TAG_VERSION_ID, DirType.GPS, TagFormat.BYTE),
)
private val xmpTags: Map<String, TagMapper?> = hashMapOf(
ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC
)
private val rawTags: Map<String, TagMapper?> = hashMapOf(
// DNG
ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620
ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612
// ORF
ExifInterface.TAG_ORF_ASPECT_FRAME to TagMapper(OlympusImageProcessingMakernoteDirectory.TagAspectFrame, DirType.OIPM, TagFormat.LONG), // ORF_IMAGE_PROCESSING_TAGS 0x1113
ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageLength, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0102
ExifInterface.TAG_ORF_PREVIEW_IMAGE_START to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageStart, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0101
ExifInterface.TAG_ORF_THUMBNAIL_IMAGE to TagMapper(OlympusMakernoteDirectory.TAG_THUMBNAIL_IMAGE, DirType.OM, TagFormat.UNDEFINED), // ORF_MAKER_NOTE_TAGS 0x0100
// RW2
ExifInterface.TAG_RW2_ISO to TagMapper(PanasonicRawIFD0Directory.TagIso, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0017
ExifInterface.TAG_RW2_JPG_FROM_RAW to TagMapper(PanasonicRawIFD0Directory.TagJpgFromRaw, DirType.PRIFD0, TagFormat.UNDEFINED), // IFD_TIFF_TAGS 0x002E
ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorBottomBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0006
ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorLeftBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0005
ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorRightBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0007
ExifInterface.TAG_RW2_SENSOR_TOP_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorTopBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0004
)
// list of known ExifInterface tags (as of androidx.exifinterface:exifinterface:1.3.0)
// mapped to metadata-extractor tags (as of v2.14.0)
@JvmField
val allTags: Map<String, TagMapper?> = hashMapOf<String, TagMapper?>(
).apply {
putAll(baseTags)
putAll(thumbnailTags)
putAll(gpsTags)
putAll(xmpTags)
putAll(rawTags)
}
@JvmStatic
fun describeAll(exif: ExifInterface): Map<String, Map<String, String>> {
// initialize metadata-extractor directories that we will fill
// by tags converted from the ExifInterface attributes
// so that we can rely on metadata-extractor descriptions
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
return HashMap<String, Map<String, String>>().apply {
put("Exif", describeDir(exif, dirs, baseTags))
put("Exif Thumbnail", describeDir(exif, dirs, thumbnailTags))
put(Metadata.DIR_GPS, describeDir(exif, dirs, gpsTags))
put(Metadata.DIR_XMP, describeDir(exif, dirs, xmpTags))
put("Exif Raw", describeDir(exif, dirs, rawTags))
}.filterValues { it.isNotEmpty() }
}
private fun describeDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>): Map<String, String> {
val dirMap = HashMap<String, String>()
fillMetadataExtractorDir(exif, metadataExtractorDirs, tags)
for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag)) {
val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
if (mapper != null) {
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
val type = mapper.type
val tagName = dir.getTagName(type)
val description: String? = dir.getDescription(type)
if (description != null) {
dirMap[tagName] = description
} else {
Log.w(LOG_TAG, "failed to get description for tag=$exifInterfaceTag value=$value")
dirMap[tagName] = value
}
} else {
dirMap[exifInterfaceTag] = value
}
}
}
}
return dirMap
}
private fun fillMetadataExtractorDir(exif: ExifInterface, metadataExtractorDirs: Map<DirType, Directory>, tags: Map<String, TagMapper?>) {
for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag) && mapper != null) {
val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
val obj: Any? = when (mapper.format) {
TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value
TagFormat.BYTE -> value.toByteArray()
TagFormat.SHORT -> value.toShortOrNull()
TagFormat.LONG -> value.toLongOrNull()
TagFormat.RATIONAL -> toRational(value)
TagFormat.RATIONAL_ARRAY -> toRationalArray(value)
null -> null
}
if (obj != null) {
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
dir.setObject(mapper.type, obj)
}
}
}
}
}
private fun toRational(s: String?): Rational? {
s ?: return null
// convert "12345/100"
val parts = s.split("/")
if (parts.size == 2) {
val numerator = parts[0].toLongOrNull() ?: return null
val denominator = parts[1].toLongOrNull() ?: return null
return Rational(numerator, denominator)
}
// convert "123.45"
var d = s.toDoubleOrNull() ?: return null
if (d == 0.0) return Rational(0, 1)
var denominator: Long = 1
while (d != floor(d)) {
denominator *= 10
d *= 10
if (denominator > 10000000000) {
// let's not get irrational
return null
}
}
val numerator: Long = d.roundToLong()
return Rational(numerator, denominator)
}
private fun toRationalArray(s: String?): Array<Rational>? {
s ?: return null
val list = s.split(",").mapNotNull { toRational(it) }
if (list.isEmpty()) return null
return list.toTypedArray()
}
// extensions
fun ExifInterface.getSafeInt(tag: String, acceptZero: Boolean = true, save: (value: Int) -> Unit) {
if (this.hasAttribute(tag)) {
val value = this.getAttributeInt(tag, 0)
if (acceptZero || value != 0) {
save(value)
}
}
}
fun ExifInterface.getSafeDateMillis(tag: String, save: (value: Long) -> Unit) {
if (this.hasAttribute(tag)) {
// TODO TLAD parse date with "yyyy:MM:dd HH:mm:ss" or find the original long
val formattedDate = this.getAttribute(tag)
val value = formattedDate?.toLongOrNull()
if (value != null && value > 0) {
save(value)
}
}
}
}
enum class DirType {
EXIF_IFD0 {
override fun createDirectory() = ExifIFD0Directory()
},
EXIF_THUMBNAIL {
override fun createDirectory() = ExifThumbnailDirectory()
},
GPS {
override fun createDirectory() = GpsDirectory()
},
OIPM {
override fun createDirectory() = OlympusImageProcessingMakernoteDirectory()
},
OCSM {
override fun createDirectory() = OlympusCameraSettingsMakernoteDirectory()
},
OM {
override fun createDirectory() = OlympusMakernoteDirectory()
},
PRIFD0 {
override fun createDirectory() = PanasonicRawIFD0Directory()
};
abstract fun createDirectory(): Directory
}
enum class TagFormat {
ASCII, COMMENT, BYTE, SHORT, LONG, RATIONAL, RATIONAL_ARRAY, UNDEFINED
}
data class TagMapper(val type: Int, val dirType: DirType, val format: TagFormat?)

View file

@ -0,0 +1,142 @@
package deckers.thibault.aves.utils
import android.content.Context
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.os.Build
import android.text.format.Formatter
import java.text.SimpleDateFormat
import java.util.*
object MediaMetadataRetrieverHelper {
@JvmField
val allKeys = hashMapOf(
MediaMetadataRetriever.METADATA_KEY_ALBUM to "Album",
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "Album Artist",
MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist",
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "Disc Number",
MediaMetadataRetriever.METADATA_KEY_DURATION to "Duration",
MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "Exif Length",
MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "Exif Offset",
MediaMetadataRetriever.METADATA_KEY_GENRE to "Genre",
MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "Has Audio",
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "Has Video",
MediaMetadataRetriever.METADATA_KEY_LOCATION to "Location",
MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIME Type",
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
putAll(hashMapOf(
MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "Has Image",
MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "Image Count",
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "Image Height",
MediaMetadataRetriever.METADATA_KEY_IMAGE_PRIMARY to "Image Primary",
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "Image Rotation",
MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "Image Width",
MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT to "Video Frame Count",
))
}
}
private val durationFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }
// extensions
fun MediaMetadataRetriever.getSafeString(tag: Int, save: (value: String) -> Unit) {
val value = this.extractMetadata(tag)
if (value != null) save(value)
}
fun MediaMetadataRetriever.getSafeInt(tag: Int, save: (value: Int) -> Unit) {
val value = this.extractMetadata(tag)?.toIntOrNull()
if (value != null) save(value)
}
fun MediaMetadataRetriever.getSafeLong(tag: Int, save: (value: Long) -> Unit) {
val value = this.extractMetadata(tag)?.toLongOrNull()
if (value != null) save(value)
}
fun MediaMetadataRetriever.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
val dateString = this.extractMetadata(tag)
val dateMillis = Metadata.parseVideoMetadataDate(dateString)
// some entries have an invalid default date (19040101T000000.000Z) that is before Epoch time
if (dateMillis > 0) save(dateMillis)
}
fun MediaMetadataRetriever.getSafeDescription(tag: Int, context: Context, save: (value: String) -> Unit) {
val value = this.extractMetadata(tag)
if (value != null) {
when (tag) {
// format
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
val bitrate = value.toLongOrNull() ?: 0
if (bitrate > 0) Formatter.formatFileSize(context, bitrate) + "/sec" else null
}
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
val framerate = value.toDoubleOrNull() ?: 0.0
if (framerate > 0.0) "$framerate" else null
}
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
val dateMillis = value.toLongOrNull() ?: 0
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
}
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_RANGE_FULL -> "Full"
MediaFormat.COLOR_RANGE_LIMITED -> "Limited"
else -> value
}
}
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
MediaFormat.COLOR_STANDARD_BT601_PAL -> "BT.601 625 (PAL)"
MediaFormat.COLOR_STANDARD_BT601_NTSC -> "BT.601 525 (NTSC)"
MediaFormat.COLOR_STANDARD_BT2020 -> "BT.2020"
else -> value
}
}
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
MediaFormat.COLOR_TRANSFER_SDR_VIDEO -> "SMPTE 170M"
MediaFormat.COLOR_TRANSFER_ST2084 -> "SMPTE ST 2084"
MediaFormat.COLOR_TRANSFER_HLG -> "ARIB STD-B67 (HLG)"
else -> value
}
}
// hide `0` values
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
// hide
MediaMetadataRetriever.METADATA_KEY_LOCATION,
MediaMetadataRetriever.METADATA_KEY_MIMETYPE -> null
// as is
else -> value
}?.let { save(it) }
}
}
}

View file

@ -1,20 +1,37 @@
package deckers.thibault.aves.utils
import androidx.exifinterface.media.ExifInterface
import java.text.DateFormat
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
object MetadataHelper {
object Metadata {
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples:
// "+37.5090+127.0243/" (Samsung)
// "+51.3328-000.7053+113.474/" (Apple)
val VIDEO_LOCATION_PATTERN: Pattern = Pattern.compile("([+-][.0-9]+)([+-][.0-9]+).*")
// directory names, as shown when listing all metadata
const val DIR_GPS = "GPS" // from metadata-extractor
const val DIR_XMP = "XMP" // from metadata-extractor
const val DIR_MEDIA = "Media"
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
@JvmStatic
fun getOrientationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0 // all other orientations (regular, flipped...) default to an angle of 0 degree
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90
ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_FLIP_VERTICAL -> 180
ExifInterface.ORIENTATION_ROTATE_270, ExifInterface.ORIENTATION_TRANSPOSE -> 270
else -> 0
}
// interpret EXIF code to whether the image is flipped
@JvmStatic
fun isFlippedForExifCode(exifOrientation: Int): Boolean = when (exifOrientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL, ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_FLIP_VERTICAL, ExifInterface.ORIENTATION_TRANSPOSE -> true
else -> false
}
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?

View file

@ -0,0 +1,33 @@
package deckers.thibault.aves.utils
import com.drew.lang.Rational
import com.drew.metadata.Directory
import java.util.*
object MetadataExtractorHelper {
// extensions
fun Directory.getSafeDescription(tag: Int, save: (value: String) -> Unit) {
if (this.containsTag(tag)) save(this.getDescription(tag))
}
fun Directory.getSafeBoolean(tag: Int, save: (value: Boolean) -> Unit) {
if (this.containsTag(tag)) save(this.getBoolean(tag))
}
fun Directory.getSafeInt(tag: Int, save: (value: Int) -> Unit) {
if (this.containsTag(tag)) save(this.getInt(tag))
}
fun Directory.getSafeLong(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getLong(tag))
}
fun Directory.getSafeRational(tag: Int, save: (value: Rational) -> Unit) {
if (this.containsTag(tag)) save(this.getRational(tag))
}
fun Directory.getSafeDateMillis(tag: Int, save: (value: Long) -> Unit) {
if (this.containsTag(tag)) save(this.getDate(tag, null, TimeZone.getDefault()).time)
}
}

View file

@ -1,18 +1,138 @@
package deckers.thibault.aves.utils
import java.util.*
object MimeTypes {
const val IMAGE = "image"
const val DNG = "image/x-adobe-dng" // .dng
private const val IMAGE = "image"
// generic raster
private const val BMP = "image/bmp"
const val GIF = "image/gif"
const val HEIC = "image/heic"
const val HEIF = "image/heif"
const val JPEG = "image/jpeg"
const val PNG = "image/png"
const val PSD = "image/x-photoshop" // .psd
const val SVG = "image/svg+xml" // .svg
private const val HEIC = "image/heic"
private const val HEIF = "image/heif"
private const val ICO = "image/x-icon"
private const val JPEG = "image/jpeg"
private const val PCX = "image/x-pcx"
private const val PNG = "image/png"
private const val PSD = "image/x-photoshop" // aka "image/vnd.adobe.photoshop"
private const val TIFF = "image/tiff"
private const val WBMP = "image/vnd.wap.wbmp"
const val WEBP = "image/webp"
const val VIDEO = "video"
const val AVI = "video/avi"
const val MP2T = "video/mp2t" // .m2ts
const val MP4 = "video/mp4"
}
// raw raster
private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2"
private const val CRW = "image/x-canon-crw"
private const val DCR = "image/x-kodak-dcr"
private const val DNG = "image/x-adobe-dng"
private const val ERF = "image/x-epson-erf"
private const val K25 = "image/x-kodak-k25"
private const val KDC = "image/x-kodak-kdc"
private const val MRW = "image/x-minolta-mrw"
private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf"
private const val PEF = "image/x-pentax-pef"
private const val RAF = "image/x-fuji-raf"
private const val RAW = "image/x-panasonic-raw"
private const val RW2 = "image/x-panasonic-rw2"
private const val SR2 = "image/x-sony-sr2"
private const val SRF = "image/x-sony-srf"
private const val SRW = "image/x-samsung-srw"
private const val X3F = "image/x-sigma-x3f"
// vector
const val SVG = "image/svg+xml"
private const val VIDEO = "video"
private const val AVI = "video/avi"
private const val MOV = "video/quicktime"
private const val MP2T = "video/mp2t"
private const val MP4 = "video/mp4"
private const val WEBM = "video/webm"
@JvmStatic
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@JvmStatic
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
// as of Flutter v1.22.0
@JvmStatic
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0
else -> false
}
// as of metadata-extractor v2.14.0
@JvmStatic
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false
else -> true
}
// Glide automatically applies EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
@JvmStatic
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true
else -> false
}
// Thumbnails obtained from the Media Store are automatically rotated
// according to EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats
@JvmStatic
fun needRotationAfterContentResolverThumbnail(mimeType: String) = when (mimeType) {
DNG, PNG -> true
else -> false
}
@JvmStatic
fun getMimeTypeForExtension(extension: String?): String? = when (extension?.toLowerCase(Locale.ROOT)) {
// generic raster
".bmp" -> BMP
".gif" -> GIF
".heic" -> HEIC
".heif" -> HEIF
".ico" -> ICO
".jpg", ".jpeg", ".jpe" -> JPEG
".pcx" -> PCX
".png" -> PNG
".psd" -> PSD
".tiff", ".tif" -> TIFF
".wbmp" -> WBMP
".webp" -> WEBP
// raw raster
".arw" -> ARW
".cr2" -> CR2
".crw" -> CRW
".dcr" -> DCR
".dng" -> DNG
".erf" -> ERF
".k25" -> K25
".kdc" -> KDC
".mrw" -> MRW
".nef" -> NEF
".nrw" -> NRW
".orf" -> ORF
".pef" -> PEF
".raf" -> RAF
".raw" -> RAW
".rw2" -> RW2
".sr2" -> SR2
".srf" -> SRF
".srw" -> SRW
".x3f" -> X3F
// vector
".svg" -> SVG
// video
".avi" -> AVI
".m2ts" -> MP2T
".mov", ".qt" -> MOV
".mp4", ".m4a", ".m4p", ".m4b", ".m4r", ".m4v" -> MP4
else -> null
}
}

View file

@ -0,0 +1,26 @@
package deckers.thibault.aves.utils
import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPMeta
object XMP {
const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/"
const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
const val SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title"
const val DESCRIPTION_PROP_NAME = "dc:description"
const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails"
const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image"
private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US"
@Throws(XMPException::class)
fun XMPMeta.getSafeLocalizedText(propName: String, save: (value: String) -> Unit) {
if (this.doesPropertyExist(DC_SCHEMA_NS, propName)) {
val item = this.getLocalizedText(DC_SCHEMA_NS, propName, GENERIC_LANG, SPECIFIC_LANG)
// double check retrieved items as the property sometimes is reported to exist but it is actually null
if (item != null) save(item.value)
}
}
}

View file

@ -91,6 +91,7 @@ class _AvesAppState extends State<AvesApp> {
: 'debug');
});
await settings.init();
await settings.initCrashlytics();
}
void _onNewIntent() {

View file

@ -52,9 +52,9 @@ class MimeFilter extends CollectionFilter {
static String displayType(String mime) {
final patterns = [
RegExp('.*/'), // remove type, keep subtype
RegExp('(X-|VND.)'), // noisy prefixes
RegExp('(X-|VND.(WAP.)?)'), // noisy prefixes
'+XML', // noisy suffix
RegExp('ADOBE[-\.]'), // for DNG, PSD...
RegExp('ADOBE\\\.'), // for PSD
];
mime = mime.toUpperCase();
patterns.forEach((pattern) => mime = mime.replaceFirst(pattern, ''));

View file

@ -23,7 +23,7 @@ class ImageEntry {
final String sourceMimeType;
int width;
int height;
int orientationDegrees;
int sourceRotationDegrees;
final int sizeBytes;
String sourceTitle;
int _dateModifiedSecs;
@ -42,7 +42,7 @@ class ImageEntry {
this.sourceMimeType,
@required this.width,
@required this.height,
this.orientationDegrees,
this.sourceRotationDegrees,
this.sizeBytes,
this.sourceTitle,
int dateModifiedSecs,
@ -68,7 +68,7 @@ class ImageEntry {
sourceMimeType: sourceMimeType,
width: width,
height: height,
orientationDegrees: orientationDegrees,
sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes,
sourceTitle: sourceTitle,
dateModifiedSecs: dateModifiedSecs,
@ -90,7 +90,7 @@ class ImageEntry {
sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int ?? 0,
height: map['height'] as int ?? 0,
orientationDegrees: map['orientationDegrees'] as int,
sourceRotationDegrees: map['sourceRotationDegrees'] as int ?? 0,
sizeBytes: map['sizeBytes'] as int,
sourceTitle: map['title'] as String,
dateModifiedSecs: map['dateModifiedSecs'] as int,
@ -108,7 +108,7 @@ class ImageEntry {
'sourceMimeType': sourceMimeType,
'width': width,
'height': height,
'orientationDegrees': orientationDegrees,
'sourceRotationDegrees': sourceRotationDegrees,
'sizeBytes': sizeBytes,
'title': sourceTitle,
'dateModifiedSecs': dateModifiedSecs,
@ -165,7 +165,7 @@ class ImageEntry {
// guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg].contains(mimeType) || isRaw;
bool get isRaw => [MimeTypes.dng].contains(mimeType);
bool get isRaw => MimeTypes.rawImages.contains(mimeType);
bool get isVideo => mimeType.startsWith('video');
@ -173,20 +173,35 @@ class ImageEntry {
bool get isAnimated => _catalogMetadata?.isAnimated ?? false;
bool get isFlipped => _catalogMetadata?.isFlipped ?? false;
bool get canEdit => path != null;
bool get canPrint => !isVideo;
bool get canRotate => canEdit && (mimeType == MimeTypes.jpeg || mimeType == MimeTypes.png);
bool get canRotate => canEdit && canEditExif;
bool get rotated => ((isVideo && isCatalogued) ? _catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0
bool get canEditExif {
switch (mimeType.toLowerCase()) {
case MimeTypes.jpeg:
case MimeTypes.png:
case MimeTypes.webp:
return true;
default:
return false;
}
}
bool get portrait => ((isVideo && isCatalogued) ? _catalogMetadata.rotationDegrees : rotationDegrees) % 180 == 90;
double get displayAspectRatio {
if (width == 0 || height == 0) return 1;
return rotated ? height / width : width / height;
return portrait ? height / width : width / height;
}
Size get displaySize => rotated ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble());
Size get displaySize => portrait ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble());
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
@ -205,6 +220,13 @@ class ImageEntry {
return _bestDate;
}
int get rotationDegrees => catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;
set rotationDegrees(int rotationDegrees) {
sourceRotationDegrees = rotationDegrees;
catalogMetadata?.rotationDegrees = rotationDegrees;
}
int get dateModifiedSecs => _dateModifiedSecs;
set dateModifiedSecs(int dateModifiedSecs) {
@ -242,7 +264,7 @@ class ImageEntry {
String _bestTitle;
String get bestTitle {
_bestTitle ??= (_catalogMetadata != null && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
_bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle;
return _bestTitle;
}
@ -305,8 +327,8 @@ class ImageEntry {
locality: address.locality,
);
}
} catch (exception, stack) {
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates exception=$exception\n$stack');
} catch (error, stackTrace) {
debugPrint('$runtimeType locate failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
}
}
@ -356,8 +378,8 @@ class ImageEntry {
if (width is int) this.width = width;
final height = newFields['height'];
if (height is int) this.height = height;
final orientationDegrees = newFields['orientationDegrees'];
if (orientationDegrees is int) this.orientationDegrees = orientationDegrees;
final rotationDegrees = newFields['rotationDegrees'];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
imageChangeNotifier.notifyListeners();
return true;

View file

@ -28,8 +28,10 @@ class DateMetadata {
}
class CatalogMetadata {
final int contentId, dateMillis, videoRotation;
final int contentId, dateMillis;
final bool isAnimated;
bool isFlipped;
int rotationDegrees;
final String mimeType, xmpSubjects, xmpTitleDescription;
final double latitude, longitude;
Address address;
@ -39,12 +41,13 @@ class CatalogMetadata {
this.mimeType,
this.dateMillis,
this.isAnimated,
this.videoRotation,
this.isFlipped,
this.rotationDegrees,
this.xmpSubjects,
this.xmpTitleDescription,
double latitude,
double longitude,
})
})
// Geocoder throws an IllegalArgumentException when a coordinate has a funky values like 1.7056881853375E7
: latitude = latitude == null || latitude < -90.0 || latitude > 90.0 ? null : latitude,
longitude = longitude == null || longitude < -180.0 || longitude > 180.0 ? null : longitude;
@ -57,7 +60,8 @@ class CatalogMetadata {
mimeType: mimeType,
dateMillis: dateMillis,
isAnimated: isAnimated,
videoRotation: videoRotation,
isFlipped: isFlipped,
rotationDegrees: rotationDegrees,
xmpSubjects: xmpSubjects,
xmpTitleDescription: xmpTitleDescription,
latitude: latitude,
@ -67,12 +71,15 @@ class CatalogMetadata {
factory CatalogMetadata.fromMap(Map map, {bool boolAsInteger = false}) {
final isAnimated = map['isAnimated'] ?? (boolAsInteger ? 0 : false);
final isFlipped = map['isFlipped'] ?? (boolAsInteger ? 0 : false);
return CatalogMetadata(
contentId: map['contentId'],
mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0,
isAnimated: boolAsInteger ? isAnimated != 0 : isAnimated,
videoRotation: map['videoRotation'] ?? 0,
isFlipped: boolAsInteger ? isFlipped != 0 : isFlipped,
// `rotationDegrees` should default to `sourceRotationDegrees`, not 0
rotationDegrees: map['rotationDegrees'],
xmpSubjects: map['xmpSubjects'] ?? '',
xmpTitleDescription: map['xmpTitleDescription'] ?? '',
latitude: map['latitude'],
@ -85,7 +92,8 @@ class CatalogMetadata {
'mimeType': mimeType,
'dateMillis': dateMillis,
'isAnimated': boolAsInteger ? (isAnimated ? 1 : 0) : isAnimated,
'videoRotation': videoRotation,
'isFlipped': boolAsInteger ? (isFlipped ? 1 : 0) : isFlipped,
'rotationDegrees': rotationDegrees,
'xmpSubjects': xmpSubjects,
'xmpTitleDescription': xmpTitleDescription,
'latitude': latitude,
@ -94,7 +102,7 @@ class CatalogMetadata {
@override
String toString() {
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, videoRotation=$videoRotation, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}';
}
}

View file

@ -33,7 +33,7 @@ class MetadataDb {
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', orientationDegrees INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
@ -49,7 +49,8 @@ class MetadataDb {
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', videoRotation INTEGER'
', isFlipped INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
@ -68,7 +69,65 @@ class MetadataDb {
', path TEXT'
')');
},
version: 1,
onUpgrade: (db, oldVersion, newVersion) async {
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
while (oldVersion < newVersion) {
if (oldVersion == 1) {
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable('
'contentId INTEGER PRIMARY KEY'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
oldVersion++;
}
}
},
version: 2,
);
}

View file

@ -1,6 +1,6 @@
class MimeTypes {
static const String anyImage = 'image/*';
static const String dng = 'image/x-adobe-dng';
static const String gif = 'image/gif';
static const String heic = 'image/heic';
static const String heif = 'image/heif';
@ -9,8 +9,33 @@ class MimeTypes {
static const String svg = 'image/svg+xml';
static const String webp = 'image/webp';
static const String arw = 'image/x-sony-arw';
static const String cr2 = 'image/x-canon-cr2';
static const String crw = 'image/x-canon-crw';
static const String dcr = 'image/x-kodak-dcr';
static const String dng = 'image/x-adobe-dng';
static const String erf = 'image/x-epson-erf';
static const String k25 = 'image/x-kodak-k25';
static const String kdc = 'image/x-kodak-kdc';
static const String mrw = 'image/x-minolta-mrw';
static const String nef = 'image/x-nikon-nef';
static const String nrw = 'image/x-nikon-nrw';
static const String orf = 'image/x-olympus-orf';
static const String pef = 'image/x-pentax-pef';
static const String raf = 'image/x-fuji-raf';
static const String raw = 'image/x-panasonic-raw';
static const String rw2 = 'image/x-panasonic-rw2';
static const String sr2 = 'image/x-sony-sr2';
static const String srf = 'image/x-sony-srf';
static const String srw = 'image/x-samsung-srw';
static const String x3f = 'image/x-sigma-x3f';
static const String anyVideo = 'video/*';
static const String avi = 'video/avi';
static const String mp2t = 'video/mp2t'; // .m2ts
static const String mp4 = 'video/mp4';
// groups
static const List<String> rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f];
}

View file

@ -53,10 +53,11 @@ class Settings extends ChangeNotifier {
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
await _setupCrashlytics();
}
Future<void> _setupCrashlytics() async {
// Crashlytics initialization is separated from the main settings initialization
// to allow settings customization without Firebase context (e.g. before a Flutter Driver test)
Future<void> initCrashlytics() async {
await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled);
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled);
}
@ -75,7 +76,7 @@ class Settings extends ChangeNotifier {
set isCrashlyticsEnabled(bool newValue) {
setAndNotify(isCrashlyticsEnabledKey, newValue);
unawaited(_setupCrashlytics());
unawaited(initCrashlytics());
}
bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, true);

View file

@ -28,7 +28,7 @@ class AndroidAppService {
} on PlatformException catch (e) {
debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return Uint8List(0);
return null;
}
static Future<Map> getEnv() async {

View file

@ -23,7 +23,7 @@ class ImageFileService {
'mimeType': entry.mimeType,
'width': entry.width,
'height': entry.height,
'orientationDegrees': entry.orientationDegrees,
'rotationDegrees': entry.rotationDegrees,
'dateModifiedSecs': entry.dateModifiedSecs,
};
}
@ -66,7 +66,7 @@ class ImageFileService {
return null;
}
static Future<Uint8List> getImage(String uri, String mimeType, {int orientationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
static Future<Uint8List> getImage(String uri, String mimeType, {int rotationDegrees, int expectedContentLength, BytesReceivedCallback onBytesReceived}) {
try {
final completer = Completer<Uint8List>.sync();
final sink = _OutputBuffer();
@ -74,7 +74,7 @@ class ImageFileService {
byteChannel.receiveBroadcastStream(<String, dynamic>{
'uri': uri,
'mimeType': mimeType,
'orientationDegrees': orientationDegrees ?? 0,
'rotationDegrees': rotationDegrees ?? 0,
}).listen(
(data) {
final chunk = data as Uint8List;
@ -100,12 +100,12 @@ class ImageFileService {
} on PlatformException catch (e) {
debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return Future.sync(() => Uint8List(0));
return Future.sync(() => null);
}
static Future<Uint8List> getThumbnail(ImageEntry entry, double width, double height, {Object taskKey, int priority}) {
if (entry.isSvg) {
return Future.sync(() => Uint8List(0));
return Future.sync(() => null);
}
return servicePolicy.call(
() async {
@ -120,7 +120,7 @@ class ImageFileService {
} on PlatformException catch (e) {
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return Uint8List(0);
return null;
},
// debugLabel: 'getThumbnail width=$width, height=$height entry=${entry.filenameWithoutExtension}',
priority: priority ?? (width == 0 || height == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
@ -183,7 +183,7 @@ class ImageFileService {
static Future<Map> rotate(ImageEntry entry, {@required bool clockwise}) async {
try {
// return map with: 'width' 'height' 'orientationDegrees' (all optional)
// return map with: 'width' 'height' 'rotationDegrees' (all optional)
final result = await platform.invokeMethod('rotate', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'clockwise': clockwise,

View file

@ -34,14 +34,16 @@ class MetadataService {
// 'mimeType': MIME type as reported by metadata extractors, not Media Store (string)
// 'dateMillis': date taken in milliseconds since Epoch (long)
// 'isAnimated': animated gif/webp (bool)
// 'isFlipped': flipped according to EXIF orientation (bool)
// 'rotationDegrees': rotation degrees according to EXIF orientation or other metadata (int)
// 'latitude': latitude (double)
// 'longitude': longitude (double)
// 'videoRotation': video rotation degrees (int)
// 'xmpSubjects': ';' separated XMP subjects (string)
// 'xmpTitleDescription': XMP title or XMP description (string)
final result = await platform.invokeMethod('getCatalogMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
'uri': entry.uri,
'extension': entry.extension,
}) as Map;
result['contentId'] = entry.contentId;
return CatalogMetadata.fromMap(result);
@ -89,6 +91,32 @@ class MetadataService {
return {};
}
static Future<Map> getExifInterfaceMetadata(ImageEntry entry) async {
try {
// return map with all data available from the ExifInterface library
final result = await platform.invokeMethod('getExifInterfaceMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<Map> getMediaMetadataRetrieverMetadata(ImageEntry entry) async {
try {
// return map with all data available from the MediaMetadataRetriever
final result = await platform.invokeMethod('getMediaMetadataRetrieverMetadata', <String, dynamic>{
'uri': entry.uri,
}) as Map;
return result;
} on PlatformException catch (e) {
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
}
return {};
}
static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{
@ -113,10 +141,11 @@ class MetadataService {
return [];
}
static Future<List<Uint8List>> getXmpThumbnails(String uri) async {
static Future<List<Uint8List>> getXmpThumbnails(ImageEntry entry) async {
try {
final result = await platform.invokeMethod('getXmpThumbnails', <String, dynamic>{
'uri': uri,
'mimeType': entry.mimeType,
'uri': entry.uri,
});
return (result as List).cast<Uint8List>();
} on PlatformException catch (e) {

View file

@ -20,6 +20,8 @@ class Constants {
static const pointNemo = Tuple2(-48.876667, -123.393333);
static const int infoGroupMaxValueLength = 140;
static const List<Dependency> androidDependencies = [
Dependency(
name: 'CWAC-Document',

View file

@ -138,7 +138,7 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
final imageProvider = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
rotationDegrees: entry.rotationDegrees,
expectedContentLength: entry.sizeBytes,
);
if (imageCache.statusForKey(imageProvider).keepAlive) {

View file

@ -75,13 +75,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
Future<void> _print(ImageEntry entry) async {
final uri = entry.uri;
final mimeType = entry.mimeType;
final orientationDegrees = entry.orientationDegrees;
final rotationDegrees = entry.rotationDegrees;
final documentName = entry.bestTitle ?? 'Aves';
final doc = pdf.Document(title: documentName);
PdfImage pdfImage;
if (entry.isSvg) {
final bytes = await ImageFileService.getImage(uri, mimeType, orientationDegrees: entry.orientationDegrees);
final bytes = await ImageFileService.getImage(uri, mimeType, rotationDegrees: entry.rotationDegrees);
if (bytes != null && bytes.isNotEmpty) {
final svgRoot = await svg.fromSvgBytes(bytes, uri);
final viewBox = svgRoot.viewport.viewBox;
@ -100,7 +100,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
image: UriImage(
uri: uri,
mimeType: mimeType,
orientationDegrees: orientationDegrees,
rotationDegrees: rotationDegrees,
),
);
}

View file

@ -0,0 +1,41 @@
import 'package:aves/widgets/common/highlight_title.dart';
import 'package:expansion_tile_card/expansion_tile_card.dart';
import 'package:flutter/material.dart';
class AvesExpansionTile extends StatelessWidget {
final String title;
final List<Widget> children;
final ValueNotifier<String> expandedNotifier;
const AvesExpansionTile({
@required this.title,
@required this.children,
this.expandedNotifier,
});
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
// color used by the `ExpansionTileCard` for selected text and icons
accentColor: Colors.white,
),
child: ExpansionTileCard(
key: Key('tilecard-$title'),
value: title,
expandedNotifier: expandedNotifier,
title: HighlightTitle(
title,
fontSize: 18,
),
children: [
Divider(thickness: 1, height: 1),
SizedBox(height: 4),
...children,
],
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
),
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
// `RadioListTile` that can trigger `onChanged` on tap when already selected, if `reselectable` is true
class AvesRadioListTile<T> extends StatelessWidget {
final T value;
final T groupValue;
final ValueChanged<T> onChanged;
final bool toggleable;
final bool reselectable;
final Color activeColor;
final Widget title;
final Widget subtitle;
final Widget secondary;
final bool isThreeLine;
final bool dense;
final bool selected;
final ListTileControlAffinity controlAffinity;
final bool autofocus;
bool get checked => value == groupValue;
const AvesRadioListTile({
Key key,
@required this.value,
@required this.groupValue,
@required this.onChanged,
this.toggleable = false,
this.reselectable = false,
this.activeColor,
this.title,
this.subtitle,
this.isThreeLine = false,
this.dense,
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
this.autofocus = false,
}) : assert(toggleable != null),
assert(isThreeLine != null),
assert(!isThreeLine || subtitle != null),
assert(selected != null),
assert(controlAffinity != null),
assert(autofocus != null),
super(key: key);
@override
Widget build(BuildContext context) {
final Widget control = Radio<T>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
autofocus: autofocus,
);
Widget leading, trailing;
switch (controlAffinity) {
case ListTileControlAffinity.leading:
case ListTileControlAffinity.platform:
leading = control;
trailing = secondary;
break;
case ListTileControlAffinity.trailing:
leading = secondary;
trailing = control;
break;
}
return MergeSemantics(
child: ListTileTheme.merge(
selectedColor: activeColor ?? Theme.of(context).accentColor,
child: ListTile(
leading: leading,
title: title,
subtitle: subtitle,
trailing: trailing,
isThreeLine: isThreeLine,
dense: dense,
enabled: onChanged != null,
onTap: onChanged != null
? () {
if (toggleable && checked) {
onChanged(null);
return;
}
if (reselectable || !checked) {
onChanged(value);
}
}
: null,
selected: selected,
autofocus: autofocus,
),
),
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:aves/widgets/common/aves_radio_list_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
@ -46,7 +47,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
}
Widget _buildRadioListTile(T value, String title) {
return RadioListTile<T>(
return AvesRadioListTile<T>(
key: Key(value.toString()),
value: value,
groupValue: _selectedValue,
@ -55,6 +56,7 @@ class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
Navigator.pop(context, _selectedValue);
setState(() {});
},
reselectable: true,
title: Text(
title,
softWrap: false,

View file

@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'package:aves/services/android_app_service.dart';
@ -38,8 +37,14 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
}
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
return await decode(bytes ?? Uint8List(0));
try {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size);
if (bytes == null) return null;
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
return null;
}
}
}

View file

@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'package:aves/model/image_entry.dart';
@ -49,8 +48,14 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
}
Future<ui.Codec> _loadAsync(ThumbnailProviderKey key, DecoderCallback decode) async {
final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey);
return await decode(bytes ?? Uint8List(0));
try {
final bytes = await ImageFileService.getThumbnail(key.entry, extent, extent, taskKey: _cancellationKey);
if (bytes == null) return null;
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with path=${entry.path}, error=$error');
return null;
}
}
@override

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui show Codec;
import 'package:aves/services/image_file_service.dart';
@ -11,14 +10,14 @@ class UriImage extends ImageProvider<UriImage> {
const UriImage({
@required this.uri,
@required this.mimeType,
@required this.orientationDegrees,
@required this.rotationDegrees,
this.expectedContentLength,
this.scale = 1.0,
}) : assert(uri != null),
assert(scale != null);
final String uri, mimeType;
final int orientationDegrees, expectedContentLength;
final int rotationDegrees, expectedContentLength;
final double scale;
@override
@ -47,7 +46,7 @@ class UriImage extends ImageProvider<UriImage> {
final bytes = await ImageFileService.getImage(
uri,
mimeType,
orientationDegrees: orientationDegrees,
rotationDegrees: rotationDegrees,
expectedContentLength: expectedContentLength,
onBytesReceived: (cumulative, total) {
chunkEvents.add(ImageChunkEvent(
@ -56,7 +55,11 @@ class UriImage extends ImageProvider<UriImage> {
));
},
);
return await decode(bytes ?? Uint8List(0));
if (bytes == null) return null;
return await decode(bytes);
} catch (error) {
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
return null;
} finally {
unawaited(chunkEvents.close());
}

View file

@ -5,6 +5,8 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
@ -28,7 +30,7 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
Future<DateMetadata> _dbDateLoader;
Future<CatalogMetadata> _dbMetadataLoader;
Future<AddressDetails> _dbAddressLoader;
Future<Map> _contentResolverMetadataLoader;
Future<Map> _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader;
ImageEntry get entry => widget.entry;
@ -37,7 +39,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
@override
void initState() {
super.initState();
_initFutures();
_loadDatabase();
_loadMetadata();
}
@override
@ -87,25 +90,22 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'sourceTitle': '${entry.sourceTitle}',
'sourceMimeType': '${entry.sourceMimeType}',
'mimeType': '${entry.mimeType}',
'mimeTypeAnySubtype': '${entry.mimeTypeAnySubtype}',
}),
Divider(),
InfoRowGroup({
'dateModifiedSecs': toDateValue(entry.dateModifiedSecs, factor: 1000),
'sourceDateTakenMillis': toDateValue(entry.sourceDateTakenMillis),
'bestDate': '${entry.bestDate}',
'monthTaken': '${entry.monthTaken}',
'dayTaken': '${entry.dayTaken}',
}),
Divider(),
InfoRowGroup({
'width': '${entry.width}',
'height': '${entry.height}',
'orientationDegrees': '${entry.orientationDegrees}',
'rotated': '${entry.rotated}',
'sourceRotationDegrees': '${entry.sourceRotationDegrees}',
'rotationDegrees': '${entry.rotationDegrees}',
'portrait': '${entry.portrait}',
'displayAspectRatio': '${entry.displayAspectRatio}',
'displaySize': '${entry.displaySize}',
'megaPixels': '${entry.megaPixels}',
}),
Divider(),
InfoRowGroup({
@ -121,7 +121,9 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'isVideo': '${entry.isVideo}',
'isCatalogued': '${entry.isCatalogued}',
'isAnimated': '${entry.isAnimated}',
'isFlipped': '${entry.isFlipped}',
'canEdit': '${entry.canEdit}',
'canEditExif': '${entry.canEditExif}',
'canPrint': '${entry.canPrint}',
'canRotate': '${entry.canRotate}',
'xmpSubjects': '${entry.xmpSubjects}',
@ -165,10 +167,24 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
}
Widget _buildDbTabView() {
final catalog = entry.catalogMetadata;
return ListView(
padding: EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: Text('DB'),
),
SizedBox(width: 8),
RaisedButton(
onPressed: () async {
await metadataDb.removeIds([entry.contentId]);
_loadDatabase();
},
child: Text('Remove from DB'),
),
],
),
FutureBuilder<DateMetadata>(
future: _dbDateLoader,
builder: (context, snapshot) {
@ -203,7 +219,8 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
'mimeType': '${data.mimeType}',
'dateMillis': '${data.dateMillis}',
'isAnimated': '${data.isAnimated}',
'videoRotation': '${data.videoRotation}',
'isFlipped': '${data.isFlipped}',
'rotationDegrees': '${data.rotationDegrees}',
'latitude': '${data.latitude}',
'longitude': '${data.longitude}',
'xmpSubjects': '${data.xmpSubjects}',
@ -236,20 +253,6 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
);
},
),
Divider(),
Text('Catalog metadata:${catalog == null ? ' no data' : ''}'),
if (catalog != null)
InfoRowGroup({
'contentId': '${catalog.contentId}',
'mimeType': '${catalog.mimeType}',
'dateMillis': '${catalog.dateMillis}',
'isAnimated': '${catalog.isAnimated}',
'videoRotation': '${catalog.videoRotation}',
'latitude': '${catalog.latitude}',
'longitude': '${catalog.longitude}',
'xmpSubjects': '${catalog.xmpSubjects}',
'xmpTitleDescription': '${catalog.xmpTitleDescription}',
}),
],
);
}
@ -259,41 +262,68 @@ class _FullscreenDebugPageState extends State<FullscreenDebugPage> {
static const millisecondTimestampKeys = ['datetaken', 'datetime'];
Widget _buildContentResolverTabView() {
Widget builder(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
final key = k.toString();
var value = v?.toString() ?? 'null';
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
if (secondTimestampKeys.contains(key)) {
v *= 1000;
}
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
}
if (key == 'xmp' && v != null && v is Uint8List) {
value = String.fromCharCodes(v);
}
return MapEntry(key, value);
}));
return AvesExpansionTile(
title: title,
children: [
Container(
alignment: AlignmentDirectional.topStart,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
data,
maxValueLength: Constants.infoGroupMaxValueLength,
),
)
],
);
}
return ListView(
padding: EdgeInsets.all(16),
padding: EdgeInsets.all(8),
children: [
Text('Content Resolver (Media Store):'),
FutureBuilder<Map>(
future: _contentResolverMetadataLoader,
builder: (context, snapshot) {
if (snapshot.hasError) return Text(snapshot.error.toString());
if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink();
final data = SplayTreeMap.of(snapshot.data.map((k, v) {
final key = k.toString();
var value = v?.toString() ?? 'null';
if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) {
if (secondTimestampKeys.contains(key)) {
v *= 1000;
}
value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})';
}
if (key == 'xmp' && v != null && v is Uint8List) {
value = String.fromCharCodes(v);
}
return MapEntry(key, value);
}));
return InfoRowGroup(data);
},
builder: (context, snapshot) => builder(context, snapshot, 'Content Resolver'),
),
FutureBuilder<Map>(
future: _exifInterfaceMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Exif Interface'),
),
FutureBuilder<Map>(
future: _mediaMetadataLoader,
builder: (context, snapshot) => builder(context, snapshot, 'Media Metadata Retriever'),
),
],
);
}
void _initFutures() {
void _loadDatabase() {
_dbDateLoader = metadataDb.loadDates().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null));
setState(() {});
}
void _loadMetadata() {
_contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry);
_exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry);
_mediaMetadataLoader = MetadataService.getMediaMetadataRetrieverMetadata(entry);
setState(() {});
}
}

View file

@ -544,7 +544,7 @@ class _FullscreenVerticalPageViewState extends State<FullscreenVerticalPageView>
await UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
rotationDegrees: entry.rotationDegrees,
).evict();
// evict low quality thumbnail (without specified extents)
await ThumbnailProvider(entry: entry).evict();

View file

@ -97,12 +97,12 @@ class ImageView extends StatelessWidget {
final uriImage = UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
rotationDegrees: entry.rotationDegrees,
expectedContentLength: entry.sizeBytes,
);
child = PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
key: ValueKey('${entry.rotationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: uriImage,
// when the full image is ready, we use it in the `loadingBuilder`
// we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation

View file

@ -84,10 +84,8 @@ class BasicSection extends StatelessWidget {
}
Map<String, String> _buildVideoRows() {
final rotation = entry.catalogMetadata?.videoRotation;
return {
'Duration': entry.durationText,
if (rotation != null) 'Rotation': '$rotation°',
};
}
}

View file

@ -3,12 +3,12 @@ import 'dart:collection';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart';
import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/aves_expansion_tile.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/info/metadata_thumbnail.dart';
import 'package:collection/collection.dart';
import 'package:expansion_tile_card/expansion_tile_card.dart';
import 'package:flutter/material.dart';
class MetadataSectionSliver extends StatefulWidget {
@ -33,8 +33,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
bool get isVisible => widget.visibleNotifier.value;
static const int maxValueLength = 140;
// directory names from metadata-extractor
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor
@ -86,37 +84,22 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
}
if (index < untitledDirectoryCount + 1) {
final dir = directoriesWithoutTitle[index - 1];
return InfoRowGroup(dir.tags, maxValueLength: maxValueLength);
return InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength);
}
final dir = directoriesWithTitle[index - 1 - untitledDirectoryCount];
return Theme(
data: Theme.of(context).copyWith(
// color used by the `ExpansionTileCard` for selected text and icons
accentColor: Colors.white,
),
child: ExpansionTileCard(
key: Key('tilecard-${dir.name}'),
value: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
title: HighlightTitle(
dir.name,
fontSize: 18,
return AvesExpansionTile(
title: dir.name,
expandedNotifier: _expandedDirectoryNotifier,
children: [
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry),
Container(
alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags, maxValueLength: Constants.infoGroupMaxValueLength),
),
children: [
Divider(thickness: 1, height: 1),
SizedBox(height: 4),
if (dir.name == exifThumbnailDirectory) MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry),
if (dir.name == xmpDirectory) MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry),
if (dir.name == videoDirectory) MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry),
Container(
alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(dir.tags, maxValueLength: maxValueLength),
),
],
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
),
],
);
},
childCount: 1 + _metadata.length,

View file

@ -39,7 +39,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
_loader = MetadataService.getExifThumbnails(uri);
break;
case MetadataThumbnailSource.xmp:
_loader = MetadataService.getXmpThumbnails(uri);
_loader = MetadataService.getXmpThumbnails(entry);
break;
}
}
@ -50,7 +50,7 @@ class _MetadataThumbnailsState extends State<MetadataThumbnails> {
future: _loader,
builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) {
final turns = (entry.orientationDegrees / 90).round();
final turns = (entry.rotationDegrees / 90).round();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Container(
alignment: AlignmentDirectional.topStart,

View file

@ -80,7 +80,7 @@ class AvesVideoState extends State<AvesVideo> {
color: Colors.black,
);
final degree = entry.catalogMetadata?.videoRotation ?? 0;
final degree = entry.catalogMetadata?.rotationDegrees ?? 0;
if (degree != 0) {
child = RotatedBox(
quarterTurns: degree ~/ 90,
@ -101,7 +101,7 @@ class AvesVideoState extends State<AvesVideo> {
image: UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
orientationDegrees: entry.orientationDegrees,
rotationDegrees: entry.rotationDegrees,
expectedContentLength: entry.sizeBytes,
),
width: entry.width.toDouble(),

View file

@ -102,7 +102,7 @@ class _WelcomePageState extends State<WelcomePage> {
text: 'Allow anonymous crash reporting',
),
LabeledCheckbox(
key: Key('agree-termsCheckbox'),
key: Key('agree-checkbox'),
value: _hasAcceptedTerms,
onChanged: (v) => setState(() => _hasAcceptedTerms = v),
text: 'I agree to the terms and conditions',

View file

@ -7,14 +7,14 @@ packages:
name: _fe_analyzer_shared
url: "https://pub.dartlang.org"
source: hosted
version: "9.0.0"
version: "11.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
url: "https://pub.dartlang.org"
source: hosted
version: "0.40.2"
version: "0.40.4"
ansicolor:
dependency: transitive
description:
@ -42,7 +42,7 @@ packages:
name: async
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.0-nullsafety"
version: "2.5.0-nullsafety.1"
barcode:
dependency: transitive
description:
@ -56,7 +56,7 @@ packages:
name: boolean_selector
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0-nullsafety"
version: "2.1.0-nullsafety.1"
cached_network_image:
dependency: transitive
description:
@ -70,14 +70,14 @@ packages:
name: characters
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0-nullsafety.2"
version: "1.1.0-nullsafety.3"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0-nullsafety"
version: "1.2.0-nullsafety.1"
charts_common:
dependency: transitive
description:
@ -105,14 +105,14 @@ packages:
name: clock
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0-nullsafety"
version: "1.1.0-nullsafety.1"
collection:
dependency: "direct main"
description:
name: collection
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0-nullsafety.2"
version: "1.15.0-nullsafety.3"
console_log_handler:
dependency: transitive
description:
@ -179,14 +179,21 @@ packages:
name: fake_async
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0-nullsafety"
version: "1.2.0-nullsafety.1"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
file:
dependency: transitive
description:
name: file
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.0-nullsafety.1"
version: "6.0.0-nullsafety.2"
firebase:
dependency: transitive
description:
@ -200,7 +207,7 @@ packages:
name: firebase_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
version: "0.5.0+1"
firebase_core_platform_interface:
dependency: transitive
description:
@ -221,14 +228,14 @@ packages:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0-dev.5"
version: "0.2.1+1"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0-dev.2"
version: "1.1.1"
flushbar:
dependency: "direct main"
description:
@ -303,7 +310,7 @@ packages:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.9"
version: "1.0.11"
flutter_staggered_animations:
dependency: "direct main"
description:
@ -353,7 +360,7 @@ packages:
name: google_maps_flutter
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.32"
version: "1.0.2"
google_maps_flutter_platform_interface:
dependency: transitive
description:
@ -388,7 +395,7 @@ packages:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.15"
version: "2.1.18"
intl:
dependency: "direct main"
description:
@ -409,14 +416,14 @@ packages:
name: js
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3-nullsafety"
version: "0.6.3-nullsafety.1"
json_rpc_2:
dependency: transitive
description:
name: json_rpc_2
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
version: "2.2.2"
latlong:
dependency: "direct main"
description:
@ -451,14 +458,14 @@ packages:
name: matcher
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.10-nullsafety"
version: "0.12.10-nullsafety.1"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0-nullsafety.2"
version: "1.3.0-nullsafety.3"
mgrs_dart:
dependency: transitive
description:
@ -542,7 +549,7 @@ packages:
name: path
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0-nullsafety"
version: "1.8.0-nullsafety.1"
path_drawing:
dependency: transitive
description:
@ -563,7 +570,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.14"
version: "1.6.18"
path_provider_linux:
dependency: transitive
description:
@ -585,27 +592,34 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.4+1"
pdf:
dependency: "direct main"
description:
name: pdf
url: "https://pub.dartlang.org"
source: hosted
version: "1.11.1"
version: "1.11.2"
pedantic:
dependency: "direct main"
description:
name: pedantic
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0-nullsafety"
version: "1.10.0-nullsafety.1"
percent_indicator:
dependency: "direct main"
description:
name: percent_indicator
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.6"
version: "2.1.7+4"
permission_handler:
dependency: "direct main"
description:
@ -642,28 +656,21 @@ packages:
name: platform
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0-nullsafety.1"
platform_detect:
dependency: transitive
description:
name: platform_detect
url: "https://pub.dartlang.org"
source: hosted
version: "1.4.0"
version: "3.0.0-nullsafety.2"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
version: "1.0.3"
pool:
dependency: transitive
description:
name: pool
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.0-nullsafety"
version: "1.5.0-nullsafety.1"
positioned_tap_detector:
dependency: transitive
description:
@ -684,7 +691,7 @@ packages:
name: process
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0-nullsafety.1"
version: "4.0.0-nullsafety.2"
proj4dart:
dependency: transitive
description:
@ -740,7 +747,7 @@ packages:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.10"
version: "0.5.12"
shared_preferences_linux:
dependency: transitive
description:
@ -769,6 +776,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.2+7"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.0.1+1"
shelf:
dependency: transitive
description:
@ -808,21 +822,21 @@ packages:
name: source_map_stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0-nullsafety.1"
version: "2.1.0-nullsafety.2"
source_maps:
dependency: transitive
description:
name: source_maps
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.10-nullsafety"
version: "0.10.10-nullsafety.1"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0-nullsafety"
version: "1.8.0-nullsafety.2"
sqflite:
dependency: "direct main"
description:
@ -843,14 +857,14 @@ packages:
name: stack_trace
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0-nullsafety"
version: "1.10.0-nullsafety.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0-nullsafety"
version: "2.1.0-nullsafety.1"
stream_transform:
dependency: transitive
description:
@ -871,7 +885,7 @@ packages:
name: string_scanner
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0-nullsafety"
version: "1.1.0-nullsafety.1"
sync_http:
dependency: transitive
description:
@ -892,28 +906,28 @@ packages:
name: term_glyph
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0-nullsafety"
version: "1.2.0-nullsafety.1"
test:
dependency: "direct dev"
description:
name: test
url: "https://pub.dartlang.org"
source: hosted
version: "1.16.0-nullsafety.4"
version: "1.16.0-nullsafety.5"
test_api:
dependency: transitive
description:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.19-nullsafety"
version: "0.2.19-nullsafety.2"
test_core:
dependency: transitive
description:
name: test_core
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.12-nullsafety.4"
version: "0.3.12-nullsafety.5"
transparent_image:
dependency: transitive
description:
@ -934,7 +948,7 @@ packages:
name: typed_data
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0-nullsafety.2"
version: "1.3.0-nullsafety.3"
unicode:
dependency: transitive
description:
@ -948,7 +962,7 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "5.6.0"
version: "5.7.2"
url_launcher_linux:
dependency: transitive
description:
@ -976,7 +990,7 @@ packages:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3+2"
version: "0.1.4+1"
url_launcher_windows:
dependency: transitive
description:
@ -1011,14 +1025,14 @@ packages:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0-nullsafety.2"
version: "2.1.0-nullsafety.3"
vm_service:
dependency: transitive
description:
name: vm_service
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0+1"
version: "5.2.0"
vm_service_client:
dependency: transitive
description:
@ -1054,6 +1068,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.3"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.3"
wkt_parser:
dependency: transitive
description:
@ -1083,5 +1104,5 @@ packages:
source: hosted
version: "2.2.1"
sdks:
dart: ">=2.10.0-4.0.dev <2.10.0"
flutter: ">=1.20.0 <2.0.0"
dart: ">=2.10.0-110 <2.11.0"
flutter: ">=1.22.0 <2.0.0"

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.1.14+26
version: 1.2.1+27
# video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
shaders_1.22.1.sksl.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,9 @@
import 'package:aves/main.dart' as app;
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:path/path.dart' as path;
import 'package:pedantic/pedantic.dart';
import 'constants.dart';
@ -12,7 +13,15 @@ void main() {
// scan files copied from test assets
// we do it via the app instead of broadcasting via ADB
// because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29
unawaited(AndroidFileService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'));
AndroidFileService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg');
configureAndLaunch();
}
Future<void> configureAndLaunch() async {
await settings.init();
settings.keepScreenOn = KeepScreenOn.always;
settings.hasAcceptedTerms = false;
app.main();
}

View file

@ -11,7 +11,7 @@ import 'utils/driver_extension.dart';
FlutterDriver driver;
void main() {
group('Aves app', () {
group('[Aves app]', () {
print('adb=${[adb, ...adbDeviceParam].join(' ')}');
setUpAll(() async {
@ -60,19 +60,6 @@ void agreeToTerms() {
});
}
void groupCollection() {
test('[collection] group', () async {
await driver.tap(find.byValueKey('appbar-menu-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('menu-group'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(EntryGroupFactor.album.toString()));
await driver.tap(find.byValueKey('apply-button'));
});
}
void sortCollection() {
test('[collection] sort', () async {
await driver.tap(find.byValueKey('appbar-menu-button'));
@ -82,7 +69,18 @@ void sortCollection() {
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(EntrySortFactor.date.toString()));
await driver.tap(find.byValueKey('apply-button'));
});
}
void groupCollection() {
test('[collection] group', () async {
await driver.tap(find.byValueKey('appbar-menu-button'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('menu-group'));
await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey(EntryGroupFactor.album.toString()));
});
}