Merge branch 'develop'
This commit is contained in:
commit
13921b090a
58 changed files with 2296 additions and 1399 deletions
4
.github/workflows/check.yml
vendored
4
.github/workflows/check.yml
vendored
|
@ -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
|
||||
|
|
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?)
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)?
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -91,6 +91,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
: 'debug');
|
||||
});
|
||||
await settings.init();
|
||||
await settings.initCrashlytics();
|
||||
}
|
||||
|
||||
void _onNewIntent() {
|
||||
|
|
|
@ -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, ''));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
41
lib/widgets/common/aves_expansion_tile.dart
Normal file
41
lib/widgets/common/aves_expansion_tile.dart
Normal 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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
97
lib/widgets/common/aves_radio_list_tile.dart
Normal file
97
lib/widgets/common/aves_radio_list_tile.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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(() {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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°',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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',
|
||||
|
|
129
pubspec.lock
129
pubspec.lock
|
@ -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"
|
||||
|
|
|
@ -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
1
shaders_1.22.1.sksl.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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()));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue