Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-10-22 19:52:00 +09:00
commit ea1fe72f72
85 changed files with 2379 additions and 2724 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '1.22.1' flutter-version: '1.22.2'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

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

View file

@ -1,3 +1,13 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
}
// Flutter properties
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@ -5,31 +15,20 @@ if (localPropertiesFile.exists()) {
localProperties.load(reader) localProperties.load(reader)
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName') def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) { def flutterRoot = localProperties.getProperty('flutter.sdk')
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
// Keys
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties') def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
// for release using credentials stored in a local file // for release using credentials stored in a local file
keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) keystorePropertiesFile.withReader('UTF-8') { reader ->
keystoreProperties.load(reader)
}
} else { } else {
// for release using credentials in environment variables set up by Github Actions // for release using credentials in environment variables set up by Github Actions
// warning: in property file, single quotes should be escaped with a backslash // warning: in property file, single quotes should be escaped with a backslash
@ -42,7 +41,7 @@ if (keystorePropertiesFile.exists()) {
} }
android { android {
compileSdkVersion 30 // latest (or latest-1 if the sources of latest SDK are unavailable) compileSdkVersion 30
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
@ -54,24 +53,14 @@ android {
defaultConfig { defaultConfig {
applicationId "deckers.thibault.aves" applicationId "deckers.thibault.aves"
// some Java 8 APIs (java.util.stream, etc.) require minSdkVersion 24 // TODO TLAD try minSdkVersion 23 when kotlin migration is done
// Gradle plugin 4.0 desugaring features allow targeting older SDKs
// but Flutter (as of v1.17.3) fails to run in release mode when using Gradle plugin 4.0:
// https://github.com/flutter/flutter/issues/58247
minSdkVersion 24 minSdkVersion 24
targetSdkVersion 30 // same as compileSdkVersion targetSdkVersion 30 // same as compileSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
manifestPlaceholders = [googleApiKey:keystoreProperties['googleApiKey']] manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
} }
// compileOptions {
// // enable support for Java 8 language APIs (stream, optional, etc.)
// coreLibraryDesugaringEnabled true
// sourceCompatibility JavaVersion.VERSION_1_8
// targetCompatibility JavaVersion.VERSION_1_8
// }
signingConfigs { signingConfigs {
release { release {
keyAlias keystoreProperties['keyAlias'] keyAlias keystoreProperties['keyAlias']
@ -105,22 +94,16 @@ repositories {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
// enable support for Java 8 language APIs (stream, optional, etc.) implementation 'androidx.core:core-ktx:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9' implementation 'androidx.exifinterface:exifinterface:1.3.1'
implementation 'androidx.core:core:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
implementation "androidx.exifinterface:exifinterface:1.3.0"
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.15.0' implementation 'com.drewnoakes:metadata-extractor:2.15.0'
implementation 'com.github.bumptech.glide:glide:4.11.0' implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation 'com.google.guava:guava:29.0-android' implementation 'com.google.guava:guava:30.0-android'
annotationProcessor 'androidx.annotation:annotation:1.1.0' kapt 'androidx.annotation:annotation:1.1.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' kapt 'com.github.bumptech.glide:compiler:4.11.0'
compileOnly rootProject.findProject(':streams_channel') compileOnly rootProject.findProject(':streams_channel')
} }
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'

View file

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="deckers.thibault.aves">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View file

@ -24,7 +24,8 @@
<!-- request write permission until Q (29) included, because scoped storage is unusable --> <!-- request write permission until Q (29) included, because scoped storage is unusable -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- to access media with unredacted metadata with scoped storage (Android Q+) --> <!-- to access media with unredacted metadata with scoped storage (Android Q+) -->

View file

@ -1,283 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class AppAdapterHandler implements MethodChannel.MethodCallHandler {
private static final String LOG_TAG = LogUtils.createTag(AppAdapterHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/app";
private Context context;
public AppAdapterHandler(Context context) {
this.context = context;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getAppIcon": {
new Thread(() -> getAppIcon(call, new MethodResultWrapper(result))).start();
break;
}
case "getAppNames": {
result.success(getAppNames());
break;
}
case "getEnv": {
result.success(getEnv());
break;
}
case "edit": {
String title = call.argument("title");
Uri uri = Uri.parse(call.argument("uri"));
String mimeType = call.argument("mimeType");
edit(title, uri, mimeType);
result.success(null);
break;
}
case "open": {
String title = call.argument("title");
Uri uri = Uri.parse(call.argument("uri"));
String mimeType = call.argument("mimeType");
open(title, uri, mimeType);
result.success(null);
break;
}
case "openMap": {
Uri geoUri = Uri.parse(call.argument("geoUri"));
openMap(geoUri);
result.success(null);
break;
}
case "setAs": {
String title = call.argument("title");
Uri uri = Uri.parse(call.argument("uri"));
String mimeType = call.argument("mimeType");
setAs(title, uri, mimeType);
result.success(null);
break;
}
case "share": {
String title = call.argument("title");
Map<String, List<String>> urisByMimeType = call.argument("urisByMimeType");
shareMultiple(title, urisByMimeType);
result.success(null);
break;
}
default:
result.notImplemented();
break;
}
}
private Map<String, String> getAppNames() {
Map<String, String> nameMap = new HashMap<>();
Intent intent = new Intent(Intent.ACTION_MAIN, null);
intent.addCategory(Intent.CATEGORY_LAUNCHER);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
// apps tend to use their name in English when creating folders
// so we get their names in English as well as the current locale
Configuration config = new Configuration();
config.setLocale(Locale.ENGLISH);
PackageManager pm = context.getPackageManager();
List<ResolveInfo> resolveInfoList = pm.queryIntentActivities(intent, 0);
for (ResolveInfo resolveInfo : resolveInfoList) {
ApplicationInfo ai = resolveInfo.activityInfo.applicationInfo;
boolean isSystemPackage = (ai.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
if (!isSystemPackage) {
String packageName = ai.packageName;
String currentLabel = String.valueOf(pm.getApplicationLabel(ai));
nameMap.put(currentLabel, packageName);
int labelRes = ai.labelRes;
if (labelRes != 0) {
try {
Resources resources = pm.getResourcesForApplication(ai);
// `updateConfiguration` is deprecated but it seems to be the only way
// to query resources from another app with a specific locale.
// The following methods do not work:
// - `resources.getConfiguration().setLocale(...)`
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
resources.updateConfiguration(config, resources.getDisplayMetrics());
String englishLabel = resources.getString(labelRes);
if (!TextUtils.equals(englishLabel, currentLabel)) {
nameMap.put(englishLabel, packageName);
}
} catch (PackageManager.NameNotFoundException e) {
Log.w(LOG_TAG, "failed to get app englishLabel for packageName=" + packageName, e);
}
}
}
}
return nameMap;
}
private void getAppIcon(MethodCall call, MethodChannel.Result result) {
String packageName = call.argument("packageName");
Double sizeDip = call.argument("sizeDip");
if (packageName == null || sizeDip == null) {
result.error("getAppIcon-args", "failed because of missing arguments", null);
return;
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
float density = context.getResources().getDisplayMetrics().density;
int size = (int) Math.round(sizeDip * density);
byte[] data = null;
try {
int iconResourceId = context.getPackageManager().getApplicationInfo(packageName, 0).icon;
Uri uri = new Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(packageName)
.path(String.valueOf(iconResourceId))
.build();
RequestOptions options = new RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.centerCrop()
.override(size, size);
FutureTarget<Bitmap> target = Glide.with(context)
.asBitmap()
.apply(options)
.load(uri)
.submit(size, size);
try {
Bitmap bitmap = target.get();
if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream);
data = stream.toByteArray();
}
} catch (Exception e) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=" + packageName, e);
}
Glide.with(context).clear(target);
} catch (PackageManager.NameNotFoundException e) {
Log.w(LOG_TAG, "failed to get app info for packageName=" + packageName, e);
return;
}
if (data != null) {
result.success(data);
} else {
result.error("getAppIcon-null", "failed to get icon for packageName=" + packageName, null);
}
}
private Map<String, String> getEnv() {
return System.getenv();
}
private void edit(String title, Uri uri, String mimeType) {
Intent intent = new Intent(Intent.ACTION_EDIT);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
intent.setDataAndType(uri, mimeType);
context.startActivity(Intent.createChooser(intent, title));
}
private void open(String title, Uri uri, String mimeType) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, mimeType);
context.startActivity(Intent.createChooser(intent, title));
}
private void openMap(Uri geoUri) {
Intent intent = new Intent(Intent.ACTION_VIEW, geoUri);
if (intent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(intent);
}
}
private void setAs(String title, Uri uri, String mimeType) {
Intent intent = new Intent(Intent.ACTION_ATTACH_DATA);
intent.setDataAndType(uri, mimeType);
context.startActivity(Intent.createChooser(intent, title));
}
private void shareSingle(String title, Uri uri, String mimeType) {
Intent intent = new Intent(Intent.ACTION_SEND);
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme())) {
String path = uri.getPath();
if (path == null) return;
String applicationId = context.getApplicationContext().getPackageName();
Uri apkUri = FileProvider.getUriForFile(context, applicationId + ".fileprovider", new File(path));
intent.putExtra(Intent.EXTRA_STREAM, apkUri);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else {
intent.putExtra(Intent.EXTRA_STREAM, uri);
}
intent.setType(mimeType);
context.startActivity(Intent.createChooser(intent, title));
}
private void shareMultiple(String title, @Nullable Map<String, List<String>> urisByMimeType) {
if (urisByMimeType == null) return;
ArrayList<Uri> uriList = urisByMimeType.values().stream().flatMap(Collection::stream).map(Uri::parse).collect(Collectors.toCollection(ArrayList::new));
String[] mimeTypes = urisByMimeType.keySet().toArray(new String[0]);
// simplify share intent for a single item, as some apps can handle one item but not more
if (uriList.size() == 1) {
shareSingle(title, uriList.get(0), mimeTypes[0]);
return;
}
String mimeType = "*/*";
if (mimeTypes.length == 1) {
// items have the same mime type & subtype
mimeType = mimeTypes[0];
} else {
// items have different subtypes
String[] mimeTypeTypes = Arrays.stream(mimeTypes).map(mt -> mt.split("/")[0]).distinct().toArray(String[]::new);
if (mimeTypeTypes.length == 1) {
// items have the same mime type
mimeType = mimeTypeTypes[0] + "/*";
}
}
Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList);
intent.setType(mimeType);
context.startActivity(Intent.createChooser(intent, title));
}
}

View file

@ -1,82 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import java.util.List;
import deckers.thibault.aves.MainActivity;
import deckers.thibault.aves.R;
import deckers.thibault.aves.utils.BitmapUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class AppShortcutHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/shortcut";
private Context context;
public AppShortcutHandler(Context context) {
this.context = context;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "canPin": {
result.success(ShortcutManagerCompat.isRequestPinShortcutSupported(context));
break;
}
case "pin": {
String label = call.argument("label");
byte[] iconBytes = call.argument("iconBytes");
List<String> filters = call.argument("filters");
new Thread(() -> pin(label, iconBytes, filters)).start();
result.success(null);
break;
}
default:
result.notImplemented();
break;
}
}
private void pin(String label, byte[] iconBytes, @Nullable List<String> filters) {
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context) || filters == null) {
return;
}
IconCompat icon = null;
if (iconBytes != null && iconBytes.length > 0) {
Bitmap bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.length);
bitmap = BitmapUtils.centerSquareCrop(context, bitmap, 256);
if (bitmap != null) {
icon = IconCompat.createWithBitmap(bitmap);
}
}
if (icon == null) {
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection);
}
Intent intent = new Intent(Intent.ACTION_MAIN, null, context, MainActivity.class)
.putExtra("page", "/collection")
.putExtra("filters", filters.toArray(new String[0]));
ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(context, "collection-" + TextUtils.join("-", filters))
.setShortLabel(label)
.setIcon(icon)
.setIntent(intent)
.build();
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null);
}
}

View file

@ -1,218 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.MediaStore;
import android.util.Log;
import android.util.Size;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.signature.ObjectKey;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.concurrent.ExecutionException;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.MethodChannel;
public class ImageDecodeTask extends AsyncTask<ImageDecodeTask.Params, Void, ImageDecodeTask.Result> {
private static final String LOG_TAG = LogUtils.createTag(ImageDecodeTask.class);
static class Params {
Uri uri;
String mimeType;
Long dateModifiedSecs;
Integer rotationDegrees, width, height, defaultSize;
Boolean isFlipped;
MethodChannel.Result result;
Params(@NonNull String uri, @NonNull String mimeType, @NonNull Long dateModifiedSecs, @NonNull Integer rotationDegrees, @NonNull Boolean isFlipped, @Nullable Integer width, @Nullable Integer height, Integer defaultSize, MethodChannel.Result result) {
this.uri = Uri.parse(uri);
this.mimeType = mimeType;
this.dateModifiedSecs = dateModifiedSecs;
this.rotationDegrees = rotationDegrees;
this.isFlipped = isFlipped;
this.width = width;
this.height = height;
this.result = result;
this.defaultSize = defaultSize;
}
}
static class Result {
Params params;
byte[] data;
String errorDetails;
Result(Params params, byte[] data, String errorDetails) {
this.params = params;
this.data = data;
this.errorDetails = errorDetails;
}
}
@SuppressLint("StaticFieldLeak")
private Activity activity;
ImageDecodeTask(Activity activity) {
this.activity = activity;
}
@Override
protected Result doInBackground(Params... params) {
Params p = params[0];
Bitmap bitmap = null;
Exception exception = null;
if (!this.isCancelled()) {
Integer w = p.width;
Integer h = p.height;
// fetch low quality thumbnails when size is not specified
if (w == null || h == null || w == 0 || h == 0) {
p.width = p.defaultSize;
p.height = p.defaultSize;
// EXIF orientations with flipping are not well supported by the Media Store:
// the content resolver may return a thumbnail that is automatically rotated
// according to EXIF orientation, but not flip it when necessary
if (!p.isFlipped) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bitmap = getThumbnailBytesByResolver(p);
} else {
bitmap = getThumbnailBytesByMediaStore(p);
}
} catch (Exception e) {
exception = e;
}
}
}
// fallback if the native methods failed or for higher quality thumbnails
try {
if (bitmap == null) {
bitmap = getThumbnailByGlide(p);
}
} catch (Exception e) {
exception = e;
}
} else {
Log.d(LOG_TAG, "getThumbnail with uri=" + p.uri + " cancelled");
}
byte[] data = null;
if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
data = stream.toByteArray();
}
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)
private Bitmap getThumbnailBytesByResolver(Params params) throws IOException {
ContentResolver resolver = activity.getContentResolver();
Bitmap bitmap = resolver.loadThumbnail(params.uri, new Size(params.width, params.height), null);
String mimeType = params.mimeType;
if (MimeTypes.needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
}
return bitmap;
}
private Bitmap getThumbnailBytesByMediaStore(Params params) {
long contentId = ContentUris.parseId(params.uri);
ContentResolver resolver = activity.getContentResolver();
if (MimeTypes.isVideo(params.mimeType)) {
return MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null);
} else {
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 = BitmapUtils.applyExifOrientation(activity, bitmap, params.rotationDegrees, params.isFlipped);
}
return bitmap;
}
}
private Bitmap getThumbnailByGlide(Params params) throws ExecutionException, InterruptedException {
Uri uri = params.uri;
String mimeType = params.mimeType;
Long dateModifiedSecs = params.dateModifiedSecs;
Integer rotationDegrees = params.rotationDegrees;
Boolean isFlipped = params.isFlipped;
int width = params.width;
int height = params.height;
RequestOptions options = new RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
// add signature to ignore cache for images which got modified but kept the same URI
.signature(new ObjectKey("" + dateModifiedSecs + rotationDegrees + isFlipped + width))
.override(width, height);
FutureTarget<Bitmap> target;
if (MimeTypes.isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE);
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(new VideoThumbnail(activity, uri))
.submit(width, height);
} else {
target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(uri)
.submit(width, height);
}
try {
Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
return bitmap;
} finally {
Glide.with(activity).clear(target);
}
}
@Override
protected void onPostExecute(Result result) {
Params params = result.params;
MethodChannel.Result r = params.result;
String uri = params.uri.toString();
if (result.data != null) {
r.success(result.data);
} else {
r.error("getThumbnail-null", "failed to get thumbnail for uri=" + uri, result.errorDetails);
}
}
}

View file

@ -1,217 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.app.Activity;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.ExifOrientationOp;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class ImageFileHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/image";
private Activity activity;
private float density;
public ImageFileHandler(Activity activity) {
this.activity = activity;
}
public float getDensity() {
if (density == 0) {
density = activity.getResources().getDisplayMetrics().density;
}
return density;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getObsoleteEntries":
new Thread(() -> getObsoleteEntries(call, new MethodResultWrapper(result))).start();
break;
case "getImageEntry":
new Thread(() -> getImageEntry(call, new MethodResultWrapper(result))).start();
break;
case "getThumbnail":
new Thread(() -> getThumbnail(call, new MethodResultWrapper(result))).start();
break;
case "clearSizedThumbnailDiskCache":
new Thread(() -> Glide.get(activity).clearDiskCache()).start();
result.success(null);
break;
case "rename":
new Thread(() -> rename(call, new MethodResultWrapper(result))).start();
break;
case "rotate":
new Thread(() -> rotate(call, new MethodResultWrapper(result))).start();
break;
case "flip":
new Thread(() -> flip(call, new MethodResultWrapper(result))).start();
break;
default:
result.notImplemented();
break;
}
}
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String uri = call.argument("uri");
String mimeType = call.argument("mimeType");
Number dateModifiedSecs = (Number)call.argument("dateModifiedSecs");
Integer rotationDegrees = call.argument("rotationDegrees");
Boolean isFlipped = call.argument("isFlipped");
Double widthDip = call.argument("widthDip");
Double heightDip = call.argument("heightDip");
Double defaultSizeDip = call.argument("defaultSizeDip");
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null);
return;
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
float density = getDensity();
int width = (int) Math.round(widthDip * density);
int height = (int) Math.round(heightDip * density);
int defaultSize = (int) Math.round(defaultSizeDip * density);
new ImageDecodeTask(activity).execute(new ImageDecodeTask.Params(uri, mimeType, dateModifiedSecs.longValue(), rotationDegrees, isFlipped, width, height, defaultSize, result));
}
private void getObsoleteEntries(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
List<Integer> known = call.argument("knownContentIds");
if (known == null) {
result.error("getObsoleteEntries-args", "failed because of missing arguments", null);
return;
}
List<Integer> obsolete = new MediaStoreImageProvider().getObsoleteContentIds(activity, known);
result.success(obsolete);
}
private void getImageEntry(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
String mimeType = call.argument("mimeType");
Uri uri = Uri.parse(call.argument("uri"));
if (uri == null || mimeType == null) {
result.error("getImageEntry-args", "failed because of missing arguments", null);
return;
}
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("getImageEntry-provider", "failed to find provider for uri=" + uri, null);
return;
}
provider.fetchSingle(activity, uri, mimeType, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> entry) {
result.success(entry);
}
@Override
public void onFailure(Throwable throwable) {
result.error("getImageEntry-failure", "failed to get entry for uri=" + uri, throwable.getMessage());
}
});
}
private void rename(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map<String, Object> entryMap = call.argument("entry");
String newName = call.argument("newName");
if (entryMap == null || newName == null) {
result.error("rename-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType");
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("rename-provider", "failed to find provider for uri=" + uri, null);
return;
}
provider.rename(activity, path, uri, mimeType, newName, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
}
@Override
public void onFailure(Throwable throwable) {
new Handler(Looper.getMainLooper()).post(() -> result.error("rename-failure", "failed to rename", throwable.getMessage()));
}
});
}
private void rotate(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map<String, Object> entryMap = call.argument("entry");
Boolean clockwise = call.argument("clockwise");
if (entryMap == null || clockwise == null) {
result.error("rotate-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType");
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("rotate-provider", "failed to find provider for uri=" + uri, null);
return;
}
ExifOrientationOp op = clockwise ? ExifOrientationOp.ROTATE_CW : ExifOrientationOp.ROTATE_CCW;
provider.changeOrientation(activity, path, uri, mimeType, op, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
}
@Override
public void onFailure(Throwable throwable) {
new Handler(Looper.getMainLooper()).post(() -> result.error("rotate-failure", "failed to rotate", throwable.getMessage()));
}
});
}
private void flip(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map<String, Object> entryMap = call.argument("entry");
if (entryMap == null) {
result.error("flip-args", "failed because of missing arguments", null);
return;
}
Uri uri = Uri.parse((String) entryMap.get("uri"));
String path = (String) entryMap.get("path");
String mimeType = (String) entryMap.get("mimeType");
ImageProvider provider = ImageProviderFactory.getProvider(uri);
if (provider == null) {
result.error("flip-provider", "failed to find provider for uri=" + uri, null);
return;
}
provider.changeOrientation(activity, path, uri, mimeType, ExifOrientationOp.FLIP, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
new Handler(Looper.getMainLooper()).post(() -> result.success(newFields));
}
@Override
public void onFailure(Throwable throwable) {
new Handler(Looper.getMainLooper()).post(() -> result.error("flip-failure", "failed to flip", throwable.getMessage()));
}
});
}
}

View file

@ -1,32 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.os.Handler;
import android.os.Looper;
import io.flutter.plugin.common.MethodChannel;
// ensure `result` methods are called on the main looper thread
public class MethodResultWrapper implements MethodChannel.Result {
private MethodChannel.Result methodResult;
private Handler handler;
MethodResultWrapper(MethodChannel.Result result) {
methodResult = result;
handler = new Handler(Looper.getMainLooper());
}
@Override
public void success(final Object result) {
handler.post(() -> methodResult.success(result));
}
@Override
public void error(final String errorCode, final String errorMessage, final Object errorDetails) {
handler.post(() -> methodResult.error(errorCode, errorMessage, errorDetails));
}
@Override
public void notImplemented() {
handler.post(() -> methodResult.notImplemented());
}
}

View file

@ -1,105 +0,0 @@
package deckers.thibault.aves.channel.calls;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.os.Build;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
public class StorageHandler implements MethodChannel.MethodCallHandler {
public static final String CHANNEL = "deckers.thibault/aves/storage";
private Context context;
public StorageHandler(Context context) {
this.context = context;
}
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getStorageVolumes": {
List<Map<String, Object>> volumes;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
volumes = getStorageVolumes();
} else {
// TODO TLAD find alternative for Android <N
volumes = new ArrayList<>();
}
result.success(volumes);
break;
}
case "getInaccessibleDirectories": {
List<String> dirPaths = call.argument("dirPaths");
if (dirPaths == null) {
result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null);
} else {
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths));
}
break;
}
case "revokeDirectoryAccess":
String path = call.argument("path");
PermissionManager.revokeDirectoryAccess(context, path);
result.success(true);
break;
case "getGrantedDirectories":
result.success(new ArrayList<>(PermissionManager.getGrantedDirs(context)));
break;
case "scanFile":
scanFile(call, new MethodResultWrapper(result));
break;
default:
result.notImplemented();
break;
}
}
@RequiresApi(api = Build.VERSION_CODES.N)
private List<Map<String, Object>> getStorageVolumes() {
List<Map<String, Object>> volumes = new ArrayList<>();
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
for (String volumePath : StorageUtils.getVolumePaths(context)) {
try {
StorageVolume volume = sm.getStorageVolume(new File(volumePath));
if (volume != null) {
Map<String, Object> volumeMap = new HashMap<>();
volumeMap.put("path", volumePath);
volumeMap.put("description", volume.getDescription(context));
volumeMap.put("isPrimary", volume.isPrimary());
volumeMap.put("isRemovable", volume.isRemovable());
volumeMap.put("isEmulated", volume.isEmulated());
volumeMap.put("state", volume.getState());
volumes.add(volumeMap);
}
} catch (IllegalArgumentException e) {
// ignore
}
}
}
return volumes;
}
private void scanFile(MethodCall call, MethodChannel.Result result) {
String path = call.argument("path");
String mimeType = call.argument("mimeType");
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, uri) -> {
result.success(uri != null ? uri.toString() : null);
});
}
}

View file

@ -1,165 +0,0 @@
package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.content.ContentResolver;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.FutureTarget;
import com.bumptech.glide.request.RequestOptions;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import deckers.thibault.aves.decoder.VideoThumbnail;
import deckers.thibault.aves.utils.BitmapUtils;
import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.EventChannel;
public class ImageByteStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/imagebytestream";
private Activity activity;
private Uri uri;
private String mimeType;
private int rotationDegrees;
private boolean isFlipped;
private EventChannel.EventSink eventSink;
private Handler handler;
@SuppressWarnings("unchecked")
public ImageByteStreamHandler(Activity activity, Object arguments) {
this.activity = activity;
if (arguments instanceof Map) {
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
this.rotationDegrees = (int) argMap.get("rotationDegrees");
this.isFlipped = (boolean) argMap.get("isFlipped");
}
}
@Override
public void onListen(Object args, EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper());
new Thread(this::getImage).start();
}
@Override
public void onCancel(Object o) {
}
private void success(final byte[] bytes) {
handler.post(() -> eventSink.success(bytes));
}
private void error(final String errorCode, final String errorMessage, final Object errorDetails) {
handler.post(() -> eventSink.error(errorCode, errorMessage, errorDetails));
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}
// Supported image formats:
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
// - 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() {
// request a fresh image with the highest quality format
RequestOptions options = new RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true);
if (MimeTypes.isVideo(mimeType)) {
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(new VideoThumbnail(activity, uri))
.submit();
try {
Bitmap bitmap = target.get();
if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream);
success(stream.toByteArray());
} else {
error("getImage-video-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
error("getImage-video-exception", "failed to get image from uri=" + uri, e.getMessage());
} finally {
Glide.with(activity).clear(target);
}
} else if (!MimeTypes.isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// we convert the image on platform side first, when Dart Image.memory does not support it
FutureTarget<Bitmap> target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(uri)
.submit();
try {
Bitmap bitmap = target.get();
if (MimeTypes.needRotationAfterGlide(mimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped);
}
if (bitmap != null) {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
if (MimeTypes.canHaveAlpha(mimeType)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
}
success(stream.toByteArray());
} else {
error("getImage-image-decode-null", "failed to get image from uri=" + uri, null);
}
} catch (Exception e) {
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);
}
} else {
ContentResolver cr = activity.getContentResolver();
try (InputStream is = cr.openInputStream(uri)) {
if (is != null) {
streamBytes(is);
} else {
error("getImage-image-read-null", "failed to get image from uri=" + uri, null);
}
} catch (IOException e) {
error("getImage-image-read-exception", "failed to get image from uri=" + uri, e.getMessage());
}
}
endOfStream();
}
private void streamBytes(InputStream inputStream) throws IOException {
int bufferSize = 2 << 17; // 256kB
byte[] buffer = new byte[bufferSize];
int len;
while ((len = inputStream.read(buffer)) != -1) {
// cannot decode image on Flutter side when using `buffer` directly...
byte[] sub = new byte[len];
System.arraycopy(buffer, 0, sub, 0, len);
success(sub);
}
}
}

View file

@ -1,152 +0,0 @@
package deckers.thibault.aves.channel.streams;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.provider.ImageProvider;
import deckers.thibault.aves.model.provider.ImageProviderFactory;
import deckers.thibault.aves.utils.LogUtils;
import io.flutter.plugin.common.EventChannel;
public class ImageOpStreamHandler implements EventChannel.StreamHandler {
private static final String LOG_TAG = LogUtils.createTag(ImageOpStreamHandler.class);
public static final String CHANNEL = "deckers.thibault/aves/imageopstream";
private Context context;
private EventChannel.EventSink eventSink;
private Handler handler;
private Map<String, Object> argMap;
private List<Map<String, Object>> entryMapList;
private String op;
@SuppressWarnings("unchecked")
public ImageOpStreamHandler(Context context, Object arguments) {
this.context = context;
if (arguments instanceof Map) {
argMap = (Map<String, Object>) arguments;
this.op = (String) argMap.get("op");
this.entryMapList = new ArrayList<>();
List<Map<String, Object>> rawEntries = (List<Map<String, Object>>) argMap.get("entries");
if (rawEntries != null) {
entryMapList.addAll(rawEntries);
}
}
}
@Override
public void onListen(Object args, EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper());
if ("delete".equals(op)) {
new Thread(this::delete).start();
} else if ("move".equals(op)) {
new Thread(this::move).start();
} else {
endOfStream();
}
}
@Override
public void onCancel(Object o) {
}
// {String uri, bool success, [Map<String, Object> newFields]}
private void success(final Map<String, Object> result) {
handler.post(() -> eventSink.success(result));
}
private void error(final String errorCode, final String errorMessage, final Object errorDetails) {
handler.post(() -> eventSink.error(errorCode, errorMessage, errorDetails));
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}
private void move() {
if (entryMapList.size() == 0) {
endOfStream();
return;
}
// assume same provider for all entries
Map<String, Object> firstEntry = entryMapList.get(0);
Uri firstUri = Uri.parse((String) firstEntry.get("uri"));
ImageProvider provider = ImageProviderFactory.getProvider(firstUri);
if (provider == null) {
error("move-provider", "failed to find provider for uri=" + firstUri, null);
return;
}
Boolean copy = (Boolean) argMap.get("copy");
String destinationDir = (String) argMap.get("destinationPath");
if (copy == null || destinationDir == null) return;
if (!destinationDir.endsWith(File.separator)) {
destinationDir += File.separator;
}
List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
provider.moveMultiple(context, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> fields) {
success(fields);
}
@Override
public void onFailure(Throwable throwable) {
error("move-failure", "failed to move entries", throwable);
}
});
endOfStream();
}
private void delete() {
if (entryMapList.size() == 0) {
endOfStream();
return;
}
// assume same provider for all entries
Map<String, Object> firstEntry = entryMapList.get(0);
Uri firstUri = Uri.parse((String) firstEntry.get("uri"));
ImageProvider provider = ImageProviderFactory.getProvider(firstUri);
if (provider == null) {
error("delete-provider", "failed to find provider for uri=" + firstUri, null);
return;
}
for (Map<String, Object> entryMap : entryMapList) {
String uriString = (String) entryMap.get("uri");
Uri uri = Uri.parse(uriString);
String path = (String) entryMap.get("path");
Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", uriString);
}};
try {
provider.delete(context, path, uri).get();
result.put("success", true);
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + path, e);
result.put("success", false);
}
success(result);
}
endOfStream();
}
}

View file

@ -1,34 +0,0 @@
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.resource.bitmap.ExifInterfaceImageHeaderParser;
import com.bumptech.glide.module.AppGlideModule;
@GlideModule
public class AvesAppGlideModule extends AppGlideModule {
@Override
public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR);
}
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
// prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383
glide.getRegistry().getImageHeaderParsers().removeIf(parser -> parser instanceof ExifInterfaceImageHeaderParser);
}
@Override
public boolean isManifestParsingEnabled() {
return false;
}
}

View file

@ -1,22 +0,0 @@
package deckers.thibault.aves.decoder;
import android.content.Context;
import android.net.Uri;
public class VideoThumbnail {
private Context mContext;
private Uri mUri;
public VideoThumbnail(Context context, Uri uri) {
mContext = context;
mUri = uri;
}
public Context getContext() {
return mContext;
}
Uri getUri() {
return mUri;
}
}

View file

@ -1,73 +0,0 @@
package deckers.thibault.aves.decoder;
import android.graphics.Bitmap;
import android.media.MediaMetadataRetriever;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.data.DataFetcher;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import deckers.thibault.aves.utils.StorageUtils;
class VideoThumbnailFetcher implements DataFetcher<InputStream> {
private final VideoThumbnail model;
VideoThumbnailFetcher(VideoThumbnail model) {
this.model = model;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
MediaMetadataRetriever retriever = StorageUtils.openMetadataRetriever(model.getContext(), model.getUri());
if (retriever != null) {
try {
byte[] picture = retriever.getEmbeddedPicture();
if (picture != null) {
callback.onDataReady(new ByteArrayInputStream(picture));
} else {
// not ideal: bitmap -> byte[] -> bitmap
// but simple fallback and we cache result
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Bitmap bitmap = retriever.getFrameAtTime();
if (bitmap != null) {
bitmap.compress(Bitmap.CompressFormat.PNG, 0, bos);
}
callback.onDataReady(new ByteArrayInputStream(bos.toByteArray()));
}
} catch (Exception e) {
callback.onLoadFailed(e);
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release();
}
}
}
@Override
public void cleanup() {
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
}
@Override
public void cancel() {
// cannot cancel
}
@NonNull
@Override
public Class<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.LOCAL;
}
}

View file

@ -1,20 +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.module.LibraryGlideModule;
import java.io.InputStream;
@GlideModule
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());
}
}

View file

@ -1,37 +0,0 @@
package deckers.thibault.aves.decoder;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
import java.io.InputStream;
class VideoThumbnailLoader implements ModelLoader<VideoThumbnail, InputStream> {
@Nullable
@Override
public LoadData<InputStream> buildLoadData(@NonNull VideoThumbnail model, int width, int height, @NonNull Options options) {
return new LoadData<>(new ObjectKey(model.getUri()), new VideoThumbnailFetcher(model));
}
@Override
public boolean handles(@NonNull VideoThumbnail videoThumbnail) {
return true;
}
static class Factory implements ModelLoaderFactory<VideoThumbnail, InputStream> {
@NonNull
@Override
public ModelLoader<VideoThumbnail, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new VideoThumbnailLoader();
}
@Override
public void teardown() {
}
}
}

View file

@ -1,47 +0,0 @@
package deckers.thibault.aves.model.provider;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
import java.util.HashMap;
import java.util.Map;
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) {
Map<String, Object> map = new HashMap<>();
map.put("uri", uri.toString());
map.put("sourceMimeType", mimeType);
String[] projection = {
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.DISPLAY_NAME,
};
try {
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
map.put("sizeBytes", cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)));
map.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
}
cursor.close();
}
} catch (Exception e) {
callback.onFailure(e);
return;
}
SourceImageEntry entry = new SourceImageEntry(map).fillPreCatalogMetadata(context);
if (entry.isSized() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));
}
}
}

View file

@ -1,36 +0,0 @@
package deckers.thibault.aves.model.provider;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import java.io.File;
import deckers.thibault.aves.model.SourceImageEntry;
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(uri, mimeType);
String path = uri.getPath();
if (path != null) {
try {
File file = new File(path);
if (file.exists()) {
entry.initFromFile(path, file.getName(), file.length(), file.lastModified() / 1000);
}
} catch (SecurityException e) {
callback.onFailure(e);
}
}
entry.fillPreCatalogMetadata(context);
if (entry.isSized() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure(new Exception("entry has no size"));
}
}
}

View file

@ -1,226 +0,0 @@
package deckers.thibault.aves.model.provider;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.provider.MediaStore;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.exifinterface.media.ExifInterface;
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.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.ExifOrientationOp;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
// *** about file access to write/rename/delete
// * primary volume
// until 28/Pie, use `File`
// on 29/Q, use `File` after setting `requestLegacyExternalStorage` flag in the manifest
// from 30/R, use `DocumentFile` (not `File`) after requesting permission to the volume root???
// * non primary volumes
// on 19/KitKat, use `DocumentFile` (not `File`) after getting permission for each file
// from 21/Lollipop, use `DocumentFile` (not `File`) after getting permission to the volume root
public abstract class ImageProvider {
private static final String LOG_TAG = LogUtils.createTag(ImageProvider.class);
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException());
}
public ListenableFuture<Object> delete(final Context context, final String path, final Uri uri) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}
public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
callback.onFailure(new UnsupportedOperationException());
}
public void rename(final Context context, final String oldPath, final Uri oldMediaUri, final String mimeType, final String newFilename, final ImageOpCallback callback) {
if (oldPath == null) {
callback.onFailure(new IllegalArgumentException("entry does not have a path, uri=" + oldMediaUri));
return;
}
File oldFile = new File(oldPath);
File newFile = new File(oldFile.getParent(), newFilename);
if (oldFile.equals(newFile)) {
Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath);
callback.onSuccess(new HashMap<>());
return;
}
DocumentFileCompat df = StorageUtils.getDocumentFile(context, oldPath, oldMediaUri);
try {
boolean renamed = df != null && df.renameTo(newFilename);
if (!renamed) {
callback.onFailure(new Exception("failed to rename entry at path=" + oldPath));
return;
}
} catch (FileNotFoundException e) {
callback.onFailure(e);
return;
}
MediaScannerConnection.scanFile(context, new String[]{oldPath}, new String[]{mimeType}, null);
scanNewPath(context, newFile.getPath(), mimeType, callback);
}
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0
private boolean canEditExif(@NonNull String mimeType) {
switch (mimeType) {
case "image/jpeg":
case "image/png":
case "image/webp":
return true;
default:
return false;
}
}
public void changeOrientation(final Context context, final String path, final Uri uri, final String mimeType, final ExifOrientationOp op, 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) {
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;
}
Map<String, Object> newFields = new HashMap<>();
try {
ExifInterface exif = new ExifInterface(editablePath);
// 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));
}
switch (op) {
case ROTATE_CW:
exif.rotate(90);
break;
case ROTATE_CCW:
exif.rotate(-90);
break;
case FLIP:
exif.flipHorizontally();
break;
}
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;
}
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (p, u) -> {
String[] projection = {MediaStore.MediaColumns.DATE_MODIFIED};
try {
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
}
cursor.close();
}
} catch (Exception e) {
callback.onFailure(e);
return;
}
callback.onSuccess(newFields);
});
}
protected void scanNewPath(final Context context, final String path, final String mimeType, final ImageOpCallback callback) {
MediaScannerConnection.scanFile(context, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> {
long contentId = 0;
Uri contentUri = null;
if (newUri != null) {
// 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 (MimeTypes.isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId);
} else if (MimeTypes.isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId);
}
}
if (contentUri == null) {
callback.onFailure(new Exception("failed to get content URI of item at path=" + path));
return;
}
Map<String, Object> newFields = new HashMap<>();
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
String[] projection = {
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
MediaStore.MediaColumns.DATE_MODIFIED,
};
try {
Cursor cursor = context.getContentResolver().query(contentUri, projection, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
newFields.put("uri", contentUri.toString());
newFields.put("contentId", contentId);
newFields.put("path", path);
newFields.put("displayName", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)));
newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)));
newFields.put("dateModifiedSecs", cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)));
}
cursor.close();
}
} catch (Exception e) {
callback.onFailure(e);
return;
}
if (newFields.isEmpty()) {
callback.onFailure(new Exception("failed to get item details from provider at contentUri=" + contentUri));
} else {
callback.onSuccess(newFields);
}
});
}
public interface ImageOpCallback {
void onSuccess(Map<String, Object> fields);
void onFailure(Throwable throwable);
}
}

View file

@ -1,28 +0,0 @@
package deckers.thibault.aves.model.provider;
import android.content.ContentResolver;
import android.net.Uri;
import android.provider.MediaStore;
import androidx.annotation.NonNull;
public class ImageProviderFactory {
public static ImageProvider getProvider(@NonNull Uri uri) {
String scheme = uri.getScheme();
if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(scheme)) {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
if (MediaStore.AUTHORITY.equalsIgnoreCase(uri.getHost())) {
return new MediaStoreImageProvider();
}
return new ContentImageProvider();
}
if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(scheme)) {
return new FileImageProvider();
}
return null;
}
}

View file

@ -1,444 +0,0 @@
package deckers.thibault.aves.model.provider;
import android.annotation.SuppressLint;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.MediaStore;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import com.commonsware.cwac.document.DocumentFileCompat;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import deckers.thibault.aves.model.AvesImageEntry;
import deckers.thibault.aves.model.SourceImageEntry;
import deckers.thibault.aves.utils.LogUtils;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.StorageUtils;
public class MediaStoreImageProvider extends ImageProvider {
private static final String LOG_TAG = LogUtils.createTag(MediaStoreImageProvider.class);
private static final String[] BASE_PROJECTION = {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.SIZE,
// TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`?
MediaStore.MediaColumns.TITLE,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DATE_MODIFIED,
};
@SuppressLint("InlinedApi")
private static final String[] IMAGE_PROJECTION = Stream.of(BASE_PROJECTION, new String[]{
// uses MediaStore.Images.Media instead of MediaStore.MediaColumns for APIs < Q
MediaStore.Images.Media.DATE_TAKEN,
MediaStore.Images.Media.ORIENTATION,
}).flatMap(Stream::of).toArray(String[]::new);
@SuppressLint("InlinedApi")
private static final String[] VIDEO_PROJECTION = Stream.of(BASE_PROJECTION, new String[]{
// uses MediaStore.Video.Media instead of MediaStore.MediaColumns for APIs < Q
MediaStore.Video.Media.DATE_TAKEN,
MediaStore.Video.Media.DURATION,
}, (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ?
new String[]{
MediaStore.Video.Media.ORIENTATION,
} : new String[0]).flatMap(Stream::of).toArray(String[]::new);
public void fetchAll(Context context, Map<Integer, Integer> knownEntries, NewEntryHandler newEntryHandler) {
NewEntryChecker isModified = (contentId, dateModifiedSecs) -> {
final Integer knownDate = knownEntries.get(contentId);
return knownDate == null || knownDate < dateModifiedSecs;
};
fetchFrom(context, isModified, newEntryHandler, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION);
fetchFrom(context, isModified, newEntryHandler, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, VIDEO_PROJECTION);
}
@Override
public void fetchSingle(@NonNull final Context context, @NonNull final Uri uri, @NonNull final String mimeType, @NonNull final ImageOpCallback callback) {
long id = ContentUris.parseId(uri);
int entryCount = 0;
NewEntryHandler onSuccess = (entry) -> {
entry.put("uri", uri.toString());
callback.onSuccess(entry);
};
NewEntryChecker alwaysValid = (contentId, dateModifiedSecs) -> true;
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 (MimeTypes.isVideo(mimeType)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION);
}
if (entryCount == 0) {
callback.onFailure(new Exception("failed to fetch entry at uri=" + uri));
}
}
public List<Integer> getObsoleteContentIds(Context context, List<Integer> knownContentIds) {
final ArrayList<Integer> current = new ArrayList<>();
current.addAll(getContentIdList(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI));
current.addAll(getContentIdList(context, MediaStore.Video.Media.EXTERNAL_CONTENT_URI));
return knownContentIds.stream().filter(id -> !current.contains(id)).collect(Collectors.toList());
}
private List<Integer> getContentIdList(Context context, Uri contentUri) {
final ArrayList<Integer> foundContentIds = new ArrayList<>();
try {
Cursor cursor = context.getContentResolver().query(contentUri, new String[]{MediaStore.MediaColumns._ID}, null, null, null);
if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
while (cursor.moveToNext()) {
foundContentIds.add(cursor.getInt(idColumn));
}
cursor.close();
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to get content IDs for contentUri=" + contentUri, e);
}
return foundContentIds;
}
@SuppressLint("InlinedApi")
private int fetchFrom(final Context context, NewEntryChecker newEntryChecker, NewEntryHandler newEntryHandler, final Uri contentUri, String[] projection) {
int newEntryCount = 0;
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) {
// image & video
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
int pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA);
int mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE);
int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE);
int titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE);
int widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH);
int heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT);
int dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED);
int dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN);
// image & video for API >= Q, only for images for API < Q
int orientationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.ORIENTATION);
// video only
int durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION);
while (cursor.moveToNext()) {
final int contentId = cursor.getInt(idColumn);
final int dateModifiedSecs = cursor.getInt(dateModifiedColumn);
if (newEntryChecker.where(contentId, dateModifiedSecs)) {
// this is fine if `contentUri` does not already contain the ID
final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId);
final String path = cursor.getString(pathColumn);
final String mimeType = cursor.getString(mimeTypeColumn);
int width = cursor.getInt(widthColumn);
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("sourceRotationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn));
put("dateModifiedSecs", dateModifiedSecs);
put("sourceDateTakenMillis", cursor.getLong(dateTakenColumn));
// only for map export
put("contentId", contentId);
}};
entryMap.put("width", width);
entryMap.put("height", height);
entryMap.put("durationMillis", durationMillis);
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);
entryMap = entry.toMap();
}
newEntryHandler.handleEntry(entryMap);
if (newEntryCount % 30 == 0) {
Thread.sleep(10);
}
newEntryCount++;
}
}
cursor.close();
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to get entries", e);
}
return newEntryCount;
}
private boolean needSize(String mimeType) {
return !MimeTypes.SVG.equals(mimeType);
}
@Override
public ListenableFuture<Object> delete(final Context context, final String path, final Uri mediaUri) {
SettableFuture<Object> future = SettableFuture.create();
if (StorageUtils.requireAccessPermission(path)) {
// if the file is on SD card, calling the content resolver delete() removes the entry from the Media Store
// but it doesn't delete the file, even if the app has the permission
try {
DocumentFileCompat df = StorageUtils.getDocumentFile(context, path, mediaUri);
if (df != null && df.delete()) {
future.set(null);
} else {
future.setException(new Exception("failed to delete file with df=" + df));
}
} catch (FileNotFoundException e) {
future.setException(e);
}
return future;
}
try {
if (context.getContentResolver().delete(mediaUri, null, null) > 0) {
future.set(null);
} else {
future.setException(new Exception("failed to delete row from content provider"));
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to delete entry", e);
future.setException(e);
}
return future;
}
private String getVolumeNameForMediaStore(@NonNull Context context, @NonNull String anyPath) {
String volumeName = "external";
StorageManager sm = context.getSystemService(StorageManager.class);
if (sm != null) {
StorageVolume volume = sm.getStorageVolume(new File(anyPath));
if (volume != null && !volume.isPrimary()) {
String uuid = volume.getUuid();
if (uuid != null) {
// the UUID returned may be uppercase
// but it should be lowercase to work with the MediaStore
volumeName = uuid.toLowerCase();
}
}
}
return volumeName;
}
@Override
public void moveMultiple(final Context context, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
DocumentFileCompat destinationDirDocFile = StorageUtils.createDirectoryIfAbsent(context, destinationDir);
if (destinationDirDocFile == null) {
callback.onFailure(new Exception("failed to create directory at path=" + destinationDir));
return;
}
MediaStoreMoveDestination destination = new MediaStoreMoveDestination(context, destinationDir);
if (destination.volumePath == null) {
callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir));
return;
}
for (AvesImageEntry entry : entries) {
Uri sourceUri = entry.uri;
String sourcePath = entry.path;
String mimeType = entry.mimeType;
Map<String, Object> result = new HashMap<String, Object>() {{
put("uri", sourceUri.toString());
}};
// on API 30 we cannot get access granted directly to a volume root from its document tree,
// but it is still less constraining to use tree document files than to rely on the Media Store
try {
ListenableFuture<Map<String, Object>> newFieldsFuture;
// if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
// newFieldsFuture = moveSingleByMediaStoreInsert(context, sourcePath, sourceUri, destination, mimeType, copy);
// } else {
newFieldsFuture = moveSingleByTreeDocAndScan(context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
// }
Map<String, Object> newFields = newFieldsFuture.get();
result.put("success", true);
result.put("newFields", newFields);
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to move to destinationDir=" + destinationDir + " entry with sourcePath=" + sourcePath, e);
result.put("success", false);
}
callback.onSuccess(result);
}
}
// We can create an item via `ContentResolver.insert()` with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
@RequiresApi(api = Build.VERSION_CODES.Q)
private ListenableFuture<Map<String, Object>> moveSingleByMediaStoreInsert(final Context context, final String sourcePath, final Uri sourceUri,
final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create();
try {
String displayName = new File(sourcePath).getName();
String destinationFilePath = destination.fullPath + displayName;
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DATA, destinationFilePath);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
String volumeName = destination.volumeNameForMediaStore;
Uri tableUrl = MimeTypes.isVideo(mimeType) ?
MediaStore.Video.Media.getContentUri(volumeName) :
MediaStore.Images.Media.getContentUri(volumeName);
Uri destinationUri = context.getContentResolver().insert(tableUrl, contentValues);
if (destinationUri == null) {
future.setException(new Exception("failed to insert row to content resolver"));
} else {
DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(context, sourceUri);
DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(context, destinationUri);
sourceFile.copyTo(destinationFile);
boolean deletedSource = false;
if (!copy) {
// delete original entry
try {
delete(context, sourcePath, sourceUri).get();
deletedSource = true;
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
}
}
Map<String, Object> newFields = new HashMap<>();
newFields.put("uri", destinationUri.toString());
newFields.put("contentId", ContentUris.parseId(destinationUri));
newFields.put("path", destinationFilePath);
newFields.put("deletedSource", deletedSource);
future.set(newFields);
}
} catch (Exception e) {
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
future.setException(e);
}
return future;
}
// We can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name
private ListenableFuture<Map<String, Object>> moveSingleByTreeDocAndScan(final Context context, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) {
SettableFuture<Map<String, Object>> future = SettableFuture.create();
try {
String sourceFileName = new File(sourcePath).getName();
String desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$", "");
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension);
DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.getUri());
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(context, sourceUri);
source.copyTo(destinationDocFile);
// the source file name and the created document file name can be different when:
// - a file with the same name already exists, so the name gets a suffix like ` (1)`
// - the original extension does not match the extension appended used by the underlying provider
String fileName = destinationDocFile.getName();
String destinationFullPath = destinationDir + fileName;
boolean deletedSource = false;
if (!copy) {
// delete original entry
try {
delete(context, sourcePath, sourceUri).get();
deletedSource = true;
} catch (ExecutionException | InterruptedException e) {
Log.w(LOG_TAG, "failed to delete entry with path=" + sourcePath, e);
}
}
boolean finalDeletedSource = deletedSource;
scanNewPath(context, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
newFields.put("deletedSource", finalDeletedSource);
future.set(newFields);
}
@Override
public void onFailure(Throwable throwable) {
future.setException(throwable);
}
});
} catch (Exception e) {
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
future.setException(e);
}
return future;
}
public interface NewEntryHandler {
void handleEntry(Map<String, Object> entry);
}
public interface NewEntryChecker {
boolean where(int contentId, int dateModifiedSecs);
}
class MediaStoreMoveDestination {
final String volumeNameForMediaStore;
final String volumePath;
final String relativePath;
final String fullPath;
MediaStoreMoveDestination(@NonNull Context context, @NonNull String destinationDir) {
fullPath = destinationDir;
volumeNameForMediaStore = getVolumeNameForMediaStore(context, destinationDir);
volumePath = StorageUtils.getVolumePath(context, destinationDir);
relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null;
}
}
}

View file

@ -26,12 +26,13 @@ class MainActivity : FlutterActivity() {
} }
private val intentStreamHandler = IntentStreamHandler() private val intentStreamHandler = IntentStreamHandler()
private var intentDataMap: MutableMap<String, Any?>? = null private lateinit var intentDataMap: MutableMap<String, Any?>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent")
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
handleIntent(intent) intentDataMap = extractIntentData(intent)
val messenger = flutterEngine!!.dartExecutor.binaryMessenger val messenger = flutterEngine!!.dartExecutor.binaryMessenger
@ -50,15 +51,13 @@ class MainActivity : FlutterActivity() {
when (call.method) { when (call.method) {
"getIntentData" -> { "getIntentData" -> {
result.success(intentDataMap) result.success(intentDataMap)
intentDataMap = null intentDataMap.clear()
} }
"pick" -> { "pick" -> {
result.success(intentDataMap) val pickedUri = call.argument<String>("uri")
intentDataMap = null if (pickedUri != null) {
val resultUri = call.argument<String>("uri")
if (resultUri != null) {
val intent = Intent().apply { val intent = Intent().apply {
data = Uri.parse(resultUri) data = Uri.parse(pickedUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
setResult(RESULT_OK, intent) setResult(RESULT_OK, intent)
@ -82,65 +81,65 @@ class MainActivity : FlutterActivity() {
// do not use 'route' as extra key, as the Flutter framework acts on it // do not use 'route' as extra key, as the Flutter framework acts on it
val search = ShortcutInfoCompat.Builder(this, "search") val search = ShortcutInfoCompat.Builder(this, "search")
.setShortLabel(getString(R.string.search_shortcut_short_label)) .setShortLabel(getString(R.string.search_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search)) .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
.setIntent(Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) .setIntent(
.putExtra("page", "/search")) Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.build() .putExtra("page", "/search")
)
.build()
val videos = ShortcutInfoCompat.Builder(this, "videos") val videos = ShortcutInfoCompat.Builder(this, "videos")
.setShortLabel(getString(R.string.videos_shortcut_short_label)) .setShortLabel(getString(R.string.videos_shortcut_short_label))
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie)) .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
.setIntent(Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java) .setIntent(
.putExtra("page", "/collection") Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))) .putExtra("page", "/collection")
.build() .putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
)
.build()
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
Log.i(LOG_TAG, "onNewIntent intent=$intent")
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) intentStreamHandler.notifyNewIntent(extractIntentData(intent))
intentStreamHandler.notifyNewIntent()
} }
private fun handleIntent(intent: Intent?) { private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
Log.i(LOG_TAG, "handleIntent intent=$intent") when (intent?.action) {
if (intent == null) return
when (intent.action) {
Intent.ACTION_MAIN -> { Intent.ACTION_MAIN -> {
val page = intent.getStringExtra("page") intent.getStringExtra("page")?.let { page ->
if (page != null) { return hashMapOf(
intentDataMap = hashMapOf( "page" to page,
"page" to page, "filters" to intent.getStringArrayExtra("filters")?.toList(),
"filters" to intent.getStringArrayExtra("filters")?.toList(),
) )
} }
} }
Intent.ACTION_VIEW -> { Intent.ACTION_VIEW -> {
val uri = intent.data intent.data?.let { uri ->
val mimeType = intent.type return hashMapOf(
if (uri != null && mimeType != null) { "action" to "view",
intentDataMap = hashMapOf( "uri" to uri.toString(),
"action" to "view", "mimeType" to intent.type, // MIME type is optional
"uri" to uri.toString(),
"mimeType" to mimeType,
) )
} }
} }
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> { Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
intentDataMap = hashMapOf( return hashMapOf(
"action" to "pick", "action" to "pick",
"mimeType" to intent.type, "mimeType" to intent.type,
) )
} }
} }
return HashMap()
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) { if (requestCode == PermissionManager.VOLUME_ACCESS_REQUEST_CODE) {
val treeUri = data.data val treeUri = data?.data
if (resultCode != RESULT_OK || treeUri == null) { if (resultCode != RESULT_OK || treeUri == null) {
PermissionManager.onPermissionResult(requestCode, null) PermissionManager.onPermissionResult(requestCode, null)
return return

View file

@ -0,0 +1,257 @@
package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.utils.LogUtils.createTag
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) }
"getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) }
"getEnv" -> result.success(System.getenv())
"edit" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(edit(title, uri, mimeType))
}
"open" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(open(title, uri, mimeType))
}
"openMap" -> {
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
result.success(openMap(geoUri))
}
"setAs" -> {
val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val mimeType = call.argument<String>("mimeType")
result.success(setAs(title, uri, mimeType))
}
"share" -> {
val title = call.argument<String>("title")
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")!!
result.success(shareMultiple(title, urisByMimeType))
}
else -> result.notImplemented()
}
}
private fun getAppNames(result: MethodChannel.Result) {
val nameMap = HashMap<String, String>()
val intent = Intent(Intent.ACTION_MAIN, null)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
// apps tend to use their name in English when creating folders
// so we get their names in English as well as the current locale
val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
val ai = resolveInfo.activityInfo.applicationInfo
val isSystemPackage = ai.flags and ApplicationInfo.FLAG_SYSTEM != 0
if (!isSystemPackage) {
val packageName = ai.packageName
val currentLabel = pm.getApplicationLabel(ai).toString()
nameMap[currentLabel] = packageName
val labelRes = ai.labelRes
if (labelRes != 0) {
try {
val resources = pm.getResourcesForApplication(ai)
// `updateConfiguration` is deprecated but it seems to be the only way
// to query resources from another app with a specific locale.
// The following methods do not work:
// - `resources.getConfiguration().setLocale(...)`
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
@Suppress("DEPRECATION")
resources.updateConfiguration(englishConfig, resources.displayMetrics)
val englishLabel = resources.getString(labelRes)
nameMap[englishLabel] = packageName
} catch (e: PackageManager.NameNotFoundException) {
Log.w(LOG_TAG, "failed to get app label in English for packageName=$packageName", e)
}
}
}
}
result.success(nameMap)
}
private fun getAppIcon(call: MethodCall, result: MethodChannel.Result) {
val packageName = call.argument<String>("packageName")
val sizeDip = call.argument<Double>("sizeDip")
if (packageName == null || sizeDip == null) {
result.error("getAppIcon-args", "failed because of missing arguments", null)
return
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
val density = context.resources.displayMetrics.density
val size = (sizeDip * density).roundToInt()
var data: ByteArray? = null
try {
val iconResourceId = context.packageManager.getApplicationInfo(packageName, 0).icon
val uri = Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(packageName)
.path(iconResourceId.toString())
.build()
val options = RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.centerCrop()
.override(size, size)
val target = Glide.with(context)
.asBitmap()
.apply(options)
.load(uri)
.submit(size, size)
try {
val bitmap = target.get()
if (bitmap != null) {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
data = stream.toByteArray()
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to decode app icon for packageName=$packageName", e)
}
Glide.with(context).clear(target)
} catch (e: PackageManager.NameNotFoundException) {
Log.w(LOG_TAG, "failed to get app info for packageName=$packageName", e)
return
}
if (data != null) {
result.success(data)
} else {
result.error("getAppIcon-null", "failed to get icon for packageName=$packageName", null)
}
}
private fun edit(title: String?, uri: Uri?, mimeType: String?): Boolean {
uri ?: return false
val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(uri, mimeType)
return safeStartActivityChooser(title, intent)
}
private fun open(title: String?, uri: Uri?, mimeType: String?): Boolean {
uri ?: return false
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mimeType)
return safeStartActivityChooser(title, intent)
}
private fun openMap(geoUri: Uri?): Boolean {
geoUri ?: return false
val intent = Intent(Intent.ACTION_VIEW, geoUri)
return safeStartActivity(intent)
}
private fun setAs(title: String?, uri: Uri?, mimeType: String?): Boolean {
uri ?: return false
val intent = Intent(Intent.ACTION_ATTACH_DATA)
.setDataAndType(uri, mimeType)
return safeStartActivityChooser(title, intent)
}
private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean {
val intent = Intent(Intent.ACTION_SEND)
.setType(mimeType)
when (uri.scheme?.toLowerCase(Locale.ROOT)) {
ContentResolver.SCHEME_FILE -> {
val path = uri.path ?: return false
val applicationId = context.applicationContext.packageName
val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path))
intent.putExtra(Intent.EXTRA_STREAM, apkUri)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
else -> intent.putExtra(Intent.EXTRA_STREAM, uri)
}
return safeStartActivityChooser(title, intent)
}
private fun shareMultiple(title: String?, urisByMimeType: Map<String, List<String>>?): Boolean {
urisByMimeType ?: return false
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { Uri.parse(it) })
val mimeTypes = urisByMimeType.keys.toTypedArray()
// simplify share intent for a single item, as some apps can handle one item but not more
if (uriList.size == 1) {
return shareSingle(title, uriList.first(), mimeTypes.first())
}
var mimeType = "*/*"
if (mimeTypes.size == 1) {
// items have the same mime type & subtype
mimeType = mimeTypes.first()
} else {
// items have different subtypes
val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct()
if (mimeTypeTypes.size == 1) {
// items have the same mime type
mimeType = "${mimeTypeTypes.first()}/*"
}
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
.setType(mimeType)
return safeStartActivityChooser(title, intent)
}
private fun safeStartActivity(intent: Intent): Boolean {
val canResolve = intent.resolveActivity(context.packageManager) != null
if (canResolve) {
context.startActivity(intent)
}
return canResolve
}
private fun safeStartActivityChooser(title: String?, intent: Intent): Boolean {
val canResolve = intent.resolveActivity(context.packageManager) != null
if (canResolve) {
context.startActivity(Intent.createChooser(intent, title))
}
return canResolve
}
companion object {
private val LOG_TAG = createTag(AppAdapterHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/app"
}
}

View file

@ -0,0 +1,66 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.R
import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"canPin" -> result.success(canPin())
"pin" -> {
GlobalScope.launch { pin(call) }
result.success(null)
}
else -> result.notImplemented()
}
}
private fun canPin() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun pin(call: MethodCall) {
if (!canPin()) return
val label = call.argument<String>("label") ?: return
val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters") ?: return
var icon: IconCompat? = null
if (iconBytes?.isNotEmpty() == true) {
var bitmap = BitmapFactory.decodeByteArray(iconBytes, 0, iconBytes.size)
bitmap = centerSquareCrop(context, bitmap, 256)
if (bitmap != null) {
icon = IconCompat.createWithBitmap(bitmap)
}
}
if (icon == null) {
icon = IconCompat.createWithResource(context, R.mipmap.ic_shortcut_collection)
}
val intent = Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
.putExtra("page", "/collection")
.putExtra("filters", filters.toTypedArray())
val shortcut = ShortcutInfoCompat.Builder(context, "collection-${filters.joinToString("-")}")
.setShortLabel(label)
.setIcon(icon)
.setIntent(intent)
.build()
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/shortcut"
}
}

View file

@ -0,0 +1,23 @@
package deckers.thibault.aves.channel.calls
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
// ensure `result` methods are called on the main looper thread
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
private val mainScope = CoroutineScope(Dispatchers.Main)
override fun success(result: Any?) {
mainScope.launch { methodResult.success(result) }
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
mainScope.launch { methodResult.error(errorCode, errorMessage, errorDetails) }
}
override fun notImplemented() {
mainScope.launch { methodResult.notImplemented() }
}
}

View file

@ -0,0 +1,170 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.net.Uri
import com.bumptech.glide.Glide
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
private val density = activity.resources.displayMetrics.density
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) }
"getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) }
"getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) }
"clearSizedThumbnailDiskCache" -> {
GlobalScope.launch { Glide.get(activity).clearDiskCache() }
result.success(null)
}
"rename" -> GlobalScope.launch { rename(call, Coresult(result)) }
"rotate" -> GlobalScope.launch { rotate(call, Coresult(result)) }
"flip" -> GlobalScope.launch { flip(call, Coresult(result)) }
else -> result.notImplemented()
}
}
private fun getObsoleteEntries(call: MethodCall, result: MethodChannel.Result) {
val known = call.argument<List<Int>>("knownContentIds")
if (known == null) {
result.error("getObsoleteEntries-args", "failed because of missing arguments", null)
return
}
result.success(MediaStoreImageProvider().getObsoleteContentIds(activity, known))
}
private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")
val mimeType = call.argument<String>("mimeType")
val dateModifiedSecs = call.argument<Number>("dateModifiedSecs")?.toLong()
val rotationDegrees = call.argument<Int>("rotationDegrees")
val isFlipped = call.argument<Boolean>("isFlipped")
val widthDip = call.argument<Double>("widthDip")
val heightDip = call.argument<Double>("heightDip")
val defaultSizeDip = call.argument<Double>("defaultSizeDip")
if (uri == null || mimeType == null || dateModifiedSecs == null || rotationDegrees == null || isFlipped == null || widthDip == null || heightDip == null || defaultSizeDip == null) {
result.error("getThumbnail-args", "failed because of missing arguments", null)
return
}
// convert DIP to physical pixels here, instead of using `devicePixelRatio` in Flutter
GlobalScope.launch {
ThumbnailFetcher(
activity,
uri,
mimeType,
dateModifiedSecs,
rotationDegrees,
isFlipped,
width = (widthDip * density).roundToInt(),
height = (heightDip * density).roundToInt(),
defaultSize = (defaultSizeDip * density).roundToInt(),
Coresult(result),
).fetch()
}
}
private fun getImageEntry(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {
result.error("getImageEntry-args", "failed because of missing arguments", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("getImageEntry-provider", "failed to find provider for uri=$uri", null)
return
}
provider.fetchSingle(activity, uri, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("getImageEntry-failure", "failed to get entry for uri=$uri", throwable.message)
})
}
private fun rename(call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
val newName = call.argument<String>("newName")
if (entryMap == null || newName == null) {
result.error("rename-args", "failed because of missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("rename-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("rename-provider", "failed to find provider for uri=$uri", null)
return
}
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
})
}
private fun rotate(call: MethodCall, result: MethodChannel.Result) {
val clockwise = call.argument<Boolean>("clockwise")
if (clockwise == null) {
result.error("rotate-args", "failed because of missing arguments", null)
return
}
val op = if (clockwise) ExifOrientationOp.ROTATE_CW else ExifOrientationOp.ROTATE_CCW
changeOrientation(call, result, op)
}
private fun flip(call: MethodCall, result: MethodChannel.Result) {
changeOrientation(call, result, ExifOrientationOp.FLIP)
}
private fun changeOrientation(call: MethodCall, result: MethodChannel.Result, op: ExifOrientationOp) {
val entryMap = call.argument<FieldMap>("entry")
if (entryMap == null) {
result.error("changeOrientation-args", "failed because of missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("changeOrientation-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("changeOrientation-provider", "failed to find provider for uri=$uri", null)
return
}
provider.changeOrientation(activity, path, uri, mimeType, op, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("changeOrientation-failure", "failed to change orientation", throwable.message)
})
}
companion object {
const val CHANNEL = "deckers.thibault/aves/image"
}
}

View file

@ -55,6 +55,8 @@ import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
@ -63,15 +65,15 @@ import kotlin.math.roundToLong
class MetadataHandler(private val context: Context) : MethodCallHandler { class MetadataHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getAllMetadata" -> Thread { getAllMetadata(call, MethodResultWrapper(result)) }.start() "getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
"getCatalogMetadata" -> Thread { getCatalogMetadata(call, MethodResultWrapper(result)) }.start() "getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
"getOverlayMetadata" -> Thread { getOverlayMetadata(call, MethodResultWrapper(result)) }.start() "getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) }
"getContentResolverMetadata" -> Thread { getContentResolverMetadata(call, MethodResultWrapper(result)) }.start() "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
"getExifInterfaceMetadata" -> Thread { getExifInterfaceMetadata(call, MethodResultWrapper(result)) }.start() "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
"getMediaMetadataRetrieverMetadata" -> Thread { getMediaMetadataRetrieverMetadata(call, MethodResultWrapper(result)) }.start() "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
"getEmbeddedPictures" -> Thread { getEmbeddedPictures(call, MethodResultWrapper(result)) }.start() "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> Thread { getExifThumbnails(call, MethodResultWrapper(result)) }.start() "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
"getXmpThumbnails" -> Thread { getXmpThumbnails(call, MethodResultWrapper(result)) }.start() "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -374,7 +376,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
val num = it.numerator val num = it.numerator
val denom = it.denominator val denom = it.denominator
metadataMap[KEY_EXPOSURE_TIME] = when { metadataMap[KEY_EXPOSURE_TIME] = when {
num >= denom -> it.toSimpleString(true) + "" num >= denom -> "${it.toSimpleString(true)}"
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString() num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
else -> it.toString() else -> it.toString()
} }

View file

@ -0,0 +1,96 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import androidx.annotation.RequiresApi
import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.File
import java.util.*
class StorageHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStorageVolumes" -> {
val volumes: List<Map<String, Any>> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
storageVolumes
} else {
// TODO TLAD find alternative for Android <N
emptyList()
}
result.success(volumes)
}
"getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
"getInaccessibleDirectories" -> getInaccessibleDirectories(call, result)
"revokeDirectoryAccess" -> revokeDirectoryAccess(call, result)
"scanFile" -> GlobalScope.launch { scanFile(call, Coresult(result)) }
else -> result.notImplemented()
}
}
private val storageVolumes: List<Map<String, Any>>
@RequiresApi(api = Build.VERSION_CODES.N)
get() {
val volumes = ArrayList<Map<String, Any>>()
val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) {
for (volumePath in getVolumePaths(context)) {
try {
sm.getStorageVolume(File(volumePath))?.let {
val volumeMap = HashMap<String, Any>()
volumeMap["path"] = volumePath
volumeMap["description"] = it.getDescription(context)
volumeMap["isPrimary"] = it.isPrimary
volumeMap["isRemovable"] = it.isRemovable
volumeMap["isEmulated"] = it.isEmulated
volumeMap["state"] = it.state
volumes.add(volumeMap)
}
} catch (e: IllegalArgumentException) {
// ignore
}
}
}
return volumes
}
private fun getInaccessibleDirectories(call: MethodCall, result: MethodChannel.Result) {
val dirPaths = call.argument<List<String>>("dirPaths")
if (dirPaths == null) {
result.error("getInaccessibleDirectories-args", "failed because of missing arguments", null)
return
}
val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths)
result.success(dirs)
}
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
if (path == null) {
result.error("revokeDirectoryAccess-args", "failed because of missing arguments", null)
return
}
val success = PermissionManager.revokeDirectoryAccess(context, path)
result.success(success)
}
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType")
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
}
companion object {
const val CHANNEL = "deckers.thibault/aves/storage"
}
}

View file

@ -0,0 +1,141 @@
package deckers.thibault.aves.channel.calls
import android.app.Activity
import android.content.ContentUris
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Size
import androidx.annotation.RequiresApi
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
class ThumbnailFetcher internal constructor(
private val activity: Activity,
uri: String,
private val mimeType: String,
private val dateModifiedSecs: Long,
private val rotationDegrees: Int,
private val isFlipped: Boolean,
width: Int?,
height: Int?,
private val defaultSize: Int,
private val result: MethodChannel.Result,
) {
val uri: Uri = Uri.parse(uri)
val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
fun fetch() {
var bitmap: Bitmap? = null
var exception: Exception? = null
// fetch low quality thumbnails when size is not specified
if ((width == defaultSize || height == defaultSize) && !isFlipped) {
// as of Android R, the Media Store content resolver may return a thumbnail
// that is automatically rotated according to EXIF orientation,
// but not flipped when necessary
// so we skip this step for flipped entries
try {
bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getByResolver() else getByMediaStore()
} catch (e: Exception) {
exception = e
}
}
// fallback if the native methods failed or for higher quality thumbnails
if (bitmap == null) {
try {
bitmap = getByGlide()
} catch (e: Exception) {
exception = e
}
}
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
result.success(stream.toByteArray())
return
}
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
result.error("getThumbnail-null", "failed to get thumbnail for uri=$uri", errorDetails)
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private fun getByResolver(): Bitmap? {
val resolver = activity.contentResolver
var bitmap: Bitmap? = resolver.loadThumbnail(uri, Size(width, height), null)
if (needRotationAfterContentResolverThumbnail(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
}
return bitmap
}
private fun getByMediaStore(): Bitmap? {
val contentId = ContentUris.parseId(uri)
val resolver = activity.contentResolver
return if (isVideo(mimeType)) {
@Suppress("DEPRECATION")
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
} else {
@Suppress("DEPRECATION")
var 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 = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
}
bitmap
}
}
private fun getByGlide(): Bitmap? {
// add signature to ignore cache for images which got modified but kept the same URI
var options = RequestOptions()
.format(DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width"))
.override(width, height)
val target = if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(activity)
.asBitmap()
.apply(options)
.load(VideoThumbnail(activity, uri))
.submit(width, height)
} else {
Glide.with(activity)
.asBitmap()
.apply(options)
.load(uri)
.submit(width, height)
}
return try {
var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
}
bitmap
} finally {
Glide.with(activity).clear(target)
}
}
}

View file

@ -0,0 +1,172 @@
package deckers.thibault.aves.channel.streams
import android.app.Activity
import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.Looper
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.MimeTypes.canHaveAlpha
import deckers.thibault.aves.utils.MimeTypes.isSupportedByFlutter
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils.openInputStream
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
class ImageByteStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
override fun onListen(args: Any, eventSink: EventSink) {
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
GlobalScope.launch { streamImage() }
}
override fun onCancel(o: Any) {}
private fun success(bytes: ByteArray) {
handler.post { eventSink.success(bytes) }
}
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
handler.post { eventSink.error(errorCode, errorMessage, errorDetails) }
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
}
// Supported image formats:
// - Flutter (as of v1.20): JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP
// - 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 fun streamImage() {
if (arguments !is Map<*, *>) {
endOfStream()
return
}
val mimeType = arguments["mimeType"] as String?
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
if (mimeType == null || uri == null) {
error("streamImage-args", "failed because of missing arguments", null)
endOfStream()
return
}
if (isVideo(mimeType)) {
streamVideoByGlide(uri)
} else if (!isSupportedByFlutter(mimeType, rotationDegrees, isFlipped)) {
// decode exotic format on platform side, then encode it in portable format for Flutter
streamImageByGlide(uri, mimeType, rotationDegrees, isFlipped)
} else {
// to be decoded by Flutter
streamImageAsIs(uri)
}
endOfStream()
}
private fun streamImageAsIs(uri: Uri) {
try {
openInputStream(activity, uri).use { input -> input?.let { streamBytes(it) } }
} catch (e: IOException) {
error("streamImage-image-read-exception", "failed to get image from uri=$uri", e.message)
}
}
private fun streamImageByGlide(uri: Uri, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(uri)
.submit()
try {
var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
}
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG, but it allows transparency
if (canHaveAlpha(mimeType)) {
bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
}
success(stream.toByteArray())
} else {
error("streamImage-image-decode-null", "failed to get image from uri=$uri", null)
}
} catch (e: Exception) {
var errorDetails = e.message
if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first()
}
error("streamImage-image-decode-exception", "failed to get image from uri=$uri", errorDetails)
} finally {
Glide.with(activity).clear(target)
}
}
private fun streamVideoByGlide(uri: Uri) {
val target = Glide.with(activity)
.asBitmap()
.apply(options)
.load(VideoThumbnail(activity, uri))
.submit()
try {
val bitmap = target.get()
if (bitmap != null) {
val stream = ByteArrayOutputStream()
// we compress the bitmap because Dart Image.memory cannot decode the raw bytes
// Bitmap.CompressFormat.PNG is slower than JPEG
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
success(stream.toByteArray())
} else {
error("streamImage-video-null", "failed to get image from uri=$uri", null)
}
} catch (e: Exception) {
error("streamImage-video-exception", "failed to get image from uri=$uri", e.message)
} finally {
Glide.with(activity).clear(target)
}
}
private fun streamBytes(inputStream: InputStream) {
val buffer = ByteArray(bufferSize)
var len: Int
while (inputStream.read(buffer).also { len = it } != -1) {
// cannot decode image on Flutter side when using `buffer` directly
success(buffer.copyOf(len))
}
}
companion object {
const val CHANNEL = "deckers.thibault/aves/imagebytestream"
const val bufferSize = 2 shl 17 // 256kB
// request a fresh image with the highest quality format
val options = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
}
}

View file

@ -0,0 +1,136 @@
package deckers.thibault.aves.channel.streams
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import deckers.thibault.aves.model.AvesImageEntry
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.ExecutionException
class ImageOpStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
private var op: String? = null
private val entryMapList = ArrayList<FieldMap>()
init {
if (arguments is Map<*, *>) {
op = arguments["op"] as String?
@Suppress("UNCHECKED_CAST")
val rawEntries = arguments["entries"] as List<FieldMap>?
if (rawEntries != null) {
entryMapList.addAll(rawEntries)
}
}
}
override fun onListen(args: Any, eventSink: EventSink) {
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
when (op) {
"delete" -> GlobalScope.launch { delete() }
"move" -> GlobalScope.launch { move() }
else -> endOfStream()
}
}
override fun onCancel(o: Any) {}
// {String uri, bool success, [Map<String, Object> newFields]}
private fun success(result: Map<String, *>) {
handler.post { eventSink.success(result) }
}
private fun error(errorCode: String, errorMessage: String, errorDetails: Any?) {
handler.post { eventSink.error(errorCode, errorMessage, errorDetails) }
}
private fun endOfStream() {
handler.post { eventSink.endOfStream() }
}
private fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("move-provider", "failed to find provider for entry=$firstEntry", null)
return
}
val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
if (copy == null || destinationDir == null) {
error("move-args", "failed because of missing arguments", null)
return
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesImageEntry)
provider.moveMultiple(context, copy, destinationDir, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
})
endOfStream()
}
private fun delete() {
if (entryMapList.isEmpty()) {
endOfStream()
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("delete-provider", "failed to find provider for entry=$firstEntry", null)
return
}
for (entryMap in entryMapList) {
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
if (uri != null) {
val result = hashMapOf<String, Any?>(
"uri" to uri.toString(),
)
try {
provider.delete(context, uri, path).get()
result["success"] = true
} catch (e: ExecutionException) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
result["success"] = false
} catch (e: InterruptedException) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
result["success"] = false
}
success(result)
}
}
endOfStream()
}
companion object {
private val LOG_TAG = createTag(ImageOpStreamHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/imageopstream"
}
}

View file

@ -4,7 +4,10 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
class IntentStreamHandler : EventChannel.StreamHandler { class IntentStreamHandler : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink // cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
// e.g. when resuming the app after the activity got destroyed
private var eventSink: EventSink? = null
override fun onListen(arguments: Any?, eventSink: EventSink) { override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink this.eventSink = eventSink
@ -12,7 +15,7 @@ class IntentStreamHandler : EventChannel.StreamHandler {
override fun onCancel(arguments: Any?) {} override fun onCancel(arguments: Any?) {}
fun notifyNewIntent() { fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
eventSink.success(true) eventSink?.success(intentData)
} }
} }

View file

@ -3,31 +3,36 @@ package deckers.thibault.aves.channel.streams
import android.content.Context import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler { class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink private lateinit var eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
private var knownEntries: Map<Int, Int>? = null
private var knownEntries: Map<Int, Int?>? = null
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
knownEntries = arguments["knownEntries"] as Map<Int, Int>? knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
} }
} }
override fun onListen(arguments: Any?, eventSink: EventSink) { override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink this.eventSink = eventSink
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
Thread { fetchAll() }.start()
GlobalScope.launch { fetchAll() }
} }
override fun onCancel(arguments: Any?) {} override fun onCancel(arguments: Any?) {}
private fun success(result: Map<String, Any>) { private fun success(result: FieldMap) {
handler.post { eventSink.success(result) } handler.post { eventSink.success(result) }
} }
@ -36,7 +41,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
} }
private fun fetchAll() { private fun fetchAll() {
MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) } MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
endOfStream() endOfStream()
} }

View file

@ -0,0 +1,27 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.util.Log
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.ImageHeaderParser
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule
@GlideModule
class AvesAppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
// hide noisy warning (e.g. for images that can't be decoded)
builder.setLogLevel(Log.ERROR)
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// prevent ExifInterface error logs
// cf https://github.com/bumptech/glide/issues/3383
glide.registry.imageHeaderParsers.removeIf { parser: ImageHeaderParser? -> parser is ExifInterfaceImageHeaderParser }
}
override fun isManifestParsingEnabled(): Boolean = false
}

View file

@ -0,0 +1,80 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.data.DataFetcher.DataCallback
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
@GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(VideoThumbnail::class.java, InputStream::class.java, VideoThumbnailLoader.Factory())
}
}
class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model))
}
override fun handles(videoThumbnail: VideoThumbnail): Boolean = true
internal class Factory : ModelLoaderFactory<VideoThumbnail, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<VideoThumbnail, InputStream> = VideoThumbnailLoader()
override fun teardown() {}
}
}
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> {
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever != null) {
try {
var picture = retriever.embeddedPicture
if (picture == null) {
// not ideal: bitmap -> byte[] -> bitmap
// but simple fallback and we cache result
val stream = ByteArrayOutputStream()
val bitmap = retriever.frameAtTime
bitmap?.compress(Bitmap.CompressFormat.PNG, 0, stream)
picture = stream.toByteArray()
}
callback.onDataReady(ByteArrayInputStream(picture))
} catch (e: Exception) {
callback.onLoadFailed(e)
} finally {
// cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs
retriever.release()
}
}
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {}
// cannot cancel
override fun cancel() {}
override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL
}

View file

@ -19,185 +19,186 @@ object ExifInterfaceHelper {
// ExifInterface always states it has the following attributes // ExifInterface always states it has the following attributes
// and returns "0" instead of "null" when they are actually missing // and returns "0" instead of "null" when they are actually missing
private val neverNullTags = listOf( private val neverNullTags = listOf(
ExifInterface.TAG_IMAGE_LENGTH, ExifInterface.TAG_IMAGE_LENGTH,
ExifInterface.TAG_IMAGE_WIDTH, ExifInterface.TAG_IMAGE_WIDTH,
ExifInterface.TAG_LIGHT_SOURCE, ExifInterface.TAG_LIGHT_SOURCE,
ExifInterface.TAG_ORIENTATION, ExifInterface.TAG_ORIENTATION,
) )
private val baseTags: Map<String, TagMapper?> = hashMapOf( private fun isNeverNull(tag: String): Boolean = neverNullTags.contains(tag)
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), private val baseTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_BODY_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_BODY_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_ARTIST to TagMapper(ExifDirectoryBase.TAG_ARTIST, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_BRIGHTNESS_VALUE to TagMapper(ExifDirectoryBase.TAG_BRIGHTNESS_VALUE, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_CAMERA_OWNER_NAME to TagMapper(ExifDirectoryBase.TAG_CAMERA_OWNER_NAME, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_BODY_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_BODY_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_CFA_PATTERN to TagMapper(ExifDirectoryBase.TAG_CFA_PATTERN, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_BRIGHTNESS_VALUE to TagMapper(ExifDirectoryBase.TAG_BRIGHTNESS_VALUE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_COLOR_SPACE to TagMapper(ExifDirectoryBase.TAG_COLOR_SPACE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_CAMERA_OWNER_NAME to TagMapper(ExifDirectoryBase.TAG_CAMERA_OWNER_NAME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_COMPONENTS_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_COMPONENTS_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_CFA_PATTERN to TagMapper(ExifDirectoryBase.TAG_CFA_PATTERN, DirType.EXIF_IFD0, TagFormat.BYTE), // spec format: UNDEFINED, e.g. [Red,Green][Green,Blue]
ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_COMPRESSED_AVERAGE_BITS_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_COLOR_SPACE to TagMapper(ExifDirectoryBase.TAG_COLOR_SPACE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_COMPRESSION to TagMapper(ExifDirectoryBase.TAG_COMPRESSION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_COMPONENTS_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_COMPONENTS_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.BYTE), // spec format: UNDEFINED, e.g. [Y,Cb,Cr]
ExifInterface.TAG_CONTRAST to TagMapper(ExifDirectoryBase.TAG_CONTRAST, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_COMPRESSED_AVERAGE_BITS_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_COPYRIGHT to TagMapper(ExifDirectoryBase.TAG_COPYRIGHT, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_COMPRESSION to TagMapper(ExifDirectoryBase.TAG_COMPRESSION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_CUSTOM_RENDERED to TagMapper(ExifDirectoryBase.TAG_CUSTOM_RENDERED, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_CONTRAST to TagMapper(ExifDirectoryBase.TAG_CONTRAST, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_DATETIME to TagMapper(ExifDirectoryBase.TAG_DATETIME, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_COPYRIGHT to TagMapper(ExifDirectoryBase.TAG_COPYRIGHT, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DATETIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_DATETIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_CUSTOM_RENDERED to TagMapper(ExifDirectoryBase.TAG_CUSTOM_RENDERED, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_DATETIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_DATETIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_DATETIME to TagMapper(ExifDirectoryBase.TAG_DATETIME, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_DEVICE_SETTING_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_DATETIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_DATETIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_DIGITAL_ZOOM_RATIO to TagMapper(ExifDirectoryBase.TAG_DIGITAL_ZOOM_RATIO, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_DATETIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_DATETIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_EXIF_VERSION to TagMapper(ExifDirectoryBase.TAG_EXIF_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_DEVICE_SETTING_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_EXPOSURE_BIAS_VALUE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_BIAS, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_DIGITAL_ZOOM_RATIO to TagMapper(ExifDirectoryBase.TAG_DIGITAL_ZOOM_RATIO, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_EXIF_VERSION to TagMapper(ExifDirectoryBase.TAG_EXIF_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_EXPOSURE_MODE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_MODE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_EXPOSURE_BIAS_VALUE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_BIAS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_PROGRAM to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_PROGRAM, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_EXPOSURE_TIME to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_TIME, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_EXPOSURE_MODE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FILE_SOURCE to TagMapper(ExifDirectoryBase.TAG_FILE_SOURCE, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_EXPOSURE_PROGRAM to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_PROGRAM, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FLASH to TagMapper(ExifDirectoryBase.TAG_FLASH, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_EXPOSURE_TIME to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_TIME, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FLASHPIX_VERSION to TagMapper(ExifDirectoryBase.TAG_FLASHPIX_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_FILE_SOURCE to TagMapper(ExifDirectoryBase.TAG_FILE_SOURCE, DirType.EXIF_IFD0, TagFormat.SHORT), // spec format: UNDEFINED
ExifInterface.TAG_FLASH_ENERGY to TagMapper(ExifDirectoryBase.TAG_FLASH_ENERGY, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_FLASH to TagMapper(ExifDirectoryBase.TAG_FLASH, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_FOCAL_LENGTH to TagMapper(ExifDirectoryBase.TAG_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_FLASHPIX_VERSION to TagMapper(ExifDirectoryBase.TAG_FLASHPIX_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM to TagMapper(ExifDirectoryBase.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_FLASH_ENERGY to TagMapper(ExifDirectoryBase.TAG_FLASH_ENERGY, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_FOCAL_LENGTH to TagMapper(ExifDirectoryBase.TAG_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_X_RESOLUTION, 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_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_F_NUMBER to TagMapper(ExifDirectoryBase.TAG_FNUMBER, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_GAIN_CONTROL to TagMapper(ExifDirectoryBase.TAG_GAIN_CONTROL, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_GAMMA to TagMapper(ExifDirectoryBase.TAG_GAMMA, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_F_NUMBER to TagMapper(ExifDirectoryBase.TAG_FNUMBER, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_IMAGE_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_IMAGE_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_GAIN_CONTROL to TagMapper(ExifDirectoryBase.TAG_GAIN_CONTROL, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_GAMMA to TagMapper(ExifDirectoryBase.TAG_GAMMA, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_IMAGE_UNIQUE_ID to TagMapper(ExifDirectoryBase.TAG_IMAGE_UNIQUE_ID, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_IMAGE_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_IMAGE_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_INTEROPERABILITY_INDEX to TagMapper(ExifDirectoryBase.TAG_INTEROP_INDEX, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_IMAGE_UNIQUE_ID to TagMapper(ExifDirectoryBase.TAG_IMAGE_UNIQUE_ID, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ISO_SPEED to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, 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_INTEROPERABILITY_INDEX to TagMapper(ExifDirectoryBase.TAG_INTEROP_INDEX, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_ZZZ, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_ISO_SPEED to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_LENS_MAKE to TagMapper(ExifDirectoryBase.TAG_LENS_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_YYY, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_LENS_MODEL to TagMapper(ExifDirectoryBase.TAG_LENS_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_ZZZ, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_LENS_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_LENS_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_LENS_MAKE to TagMapper(ExifDirectoryBase.TAG_LENS_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LENS_SPECIFICATION to TagMapper(ExifDirectoryBase.TAG_LENS_SPECIFICATION, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY), ExifInterface.TAG_LENS_MODEL to TagMapper(ExifDirectoryBase.TAG_LENS_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_LIGHT_SOURCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_LENS_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_LENS_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_MAKE to TagMapper(ExifDirectoryBase.TAG_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_LENS_SPECIFICATION to TagMapper(ExifDirectoryBase.TAG_LENS_SPECIFICATION, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_MAKER_NOTE to TagMapper(ExifDirectoryBase.TAG_MAKERNOTE, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_LIGHT_SOURCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_MAX_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_MAX_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_MAKE to TagMapper(ExifDirectoryBase.TAG_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_METERING_MODE to TagMapper(ExifDirectoryBase.TAG_METERING_MODE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_MAKER_NOTE to TagMapper(ExifDirectoryBase.TAG_MAKERNOTE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_MODEL to TagMapper(ExifDirectoryBase.TAG_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_MAX_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_MAX_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_NEW_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_NEW_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_METERING_MODE to TagMapper(ExifDirectoryBase.TAG_METERING_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_OECF to TagMapper(ExifDirectoryBase.TAG_OPTO_ELECTRIC_CONVERSION_FUNCTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_MODEL to TagMapper(ExifDirectoryBase.TAG_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_OFFSET_TIME to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_NEW_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_NEW_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_OFFSET_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_OECF to TagMapper(ExifDirectoryBase.TAG_OPTO_ELECTRIC_CONVERSION_FUNCTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_OFFSET_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_OFFSET_TIME to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_ORIENTATION to TagMapper(ExifDirectoryBase.TAG_ORIENTATION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_OFFSET_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_ISO_EQUIVALENT, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_OFFSET_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION to TagMapper(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_ORIENTATION to TagMapper(ExifDirectoryBase.TAG_ORIENTATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PIXEL_X_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_ISO_EQUIVALENT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PIXEL_Y_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION to TagMapper(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_PLANAR_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_PLANAR_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_PIXEL_X_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_PRIMARY_CHROMATICITIES to TagMapper(ExifDirectoryBase.TAG_PRIMARY_CHROMATICITIES, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_PIXEL_Y_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_RECOMMENDED_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_PLANAR_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_PLANAR_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_REFERENCE_BLACK_WHITE to TagMapper(ExifDirectoryBase.TAG_REFERENCE_BLACK_WHITE, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY), ExifInterface.TAG_PRIMARY_CHROMATICITIES to TagMapper(ExifDirectoryBase.TAG_PRIMARY_CHROMATICITIES, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_RELATED_SOUND_FILE to TagMapper(ExifDirectoryBase.TAG_RELATED_SOUND_FILE, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_RECOMMENDED_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_REFERENCE_BLACK_WHITE to TagMapper(ExifDirectoryBase.TAG_REFERENCE_BLACK_WHITE, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
ExifInterface.TAG_ROWS_PER_STRIP to TagMapper(ExifDirectoryBase.TAG_ROWS_PER_STRIP, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_RELATED_SOUND_FILE to TagMapper(ExifDirectoryBase.TAG_RELATED_SOUND_FILE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_SAMPLES_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_SAMPLES_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SATURATION to TagMapper(ExifDirectoryBase.TAG_SATURATION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_ROWS_PER_STRIP to TagMapper(ExifDirectoryBase.TAG_ROWS_PER_STRIP, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SCENE_CAPTURE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_CAPTURE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_SAMPLES_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_SAMPLES_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SCENE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_TYPE, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_SATURATION to TagMapper(ExifDirectoryBase.TAG_SATURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SENSING_METHOD to TagMapper(ExifDirectoryBase.TAG_SENSING_METHOD, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_SCENE_CAPTURE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_CAPTURE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SENSITIVITY_TYPE to TagMapper(ExifDirectoryBase.TAG_SENSITIVITY_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_SCENE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT), // spec format: UNDEFINED
ExifInterface.TAG_SHARPNESS to TagMapper(ExifDirectoryBase.TAG_SHARPNESS, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_SENSING_METHOD to TagMapper(ExifDirectoryBase.TAG_SENSING_METHOD, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SHUTTER_SPEED_VALUE to TagMapper(ExifDirectoryBase.TAG_SHUTTER_SPEED, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_SENSITIVITY_TYPE to TagMapper(ExifDirectoryBase.TAG_SENSITIVITY_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SOFTWARE to TagMapper(ExifDirectoryBase.TAG_SOFTWARE, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_SHARPNESS to TagMapper(ExifDirectoryBase.TAG_SHARPNESS, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE to TagMapper(ExifDirectoryBase.TAG_SPATIAL_FREQ_RESPONSE, DirType.EXIF_IFD0, TagFormat.UNDEFINED), ExifInterface.TAG_SHUTTER_SPEED_VALUE to TagMapper(ExifDirectoryBase.TAG_SHUTTER_SPEED, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_SPECTRAL_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_SPECTRAL_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_SOFTWARE to TagMapper(ExifDirectoryBase.TAG_SOFTWARE, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_STANDARD_OUTPUT_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE to TagMapper(ExifDirectoryBase.TAG_SPATIAL_FREQ_RESPONSE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
ExifInterface.TAG_STRIP_BYTE_COUNTS to TagMapper(ExifDirectoryBase.TAG_STRIP_BYTE_COUNTS, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_SPECTRAL_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_SPECTRAL_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_STRIP_OFFSETS to TagMapper(ExifDirectoryBase.TAG_STRIP_OFFSETS, DirType.EXIF_IFD0, TagFormat.LONG), ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_STANDARD_OUTPUT_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_STRIP_BYTE_COUNTS to TagMapper(ExifDirectoryBase.TAG_STRIP_BYTE_COUNTS, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SUBJECT_AREA to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION_TIFF_EP, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_STRIP_OFFSETS to TagMapper(ExifDirectoryBase.TAG_STRIP_OFFSETS, DirType.EXIF_IFD0, TagFormat.LONG),
ExifInterface.TAG_SUBJECT_DISTANCE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE_RANGE, 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_LOCATION to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_SUBJECT_DISTANCE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_SUBSEC_TIME to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_SUBJECT_DISTANCE_RANGE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE_RANGE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_SUBJECT_LOCATION to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_SUBSEC_TIME to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME, 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_SUBSEC_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0100 ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
ExifInterface.TAG_TRANSFER_FUNCTION to TagMapper(ExifDirectoryBase.TAG_TRANSFER_FUNCTION, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0101
ExifInterface.TAG_USER_COMMENT to TagMapper(ExifDirectoryBase.TAG_USER_COMMENT, DirType.EXIF_IFD0, TagFormat.COMMENT), ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0100
ExifInterface.TAG_WHITE_BALANCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_TRANSFER_FUNCTION to TagMapper(ExifDirectoryBase.TAG_TRANSFER_FUNCTION, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_WHITE_POINT to TagMapper(ExifDirectoryBase.TAG_WHITE_POINT, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_USER_COMMENT to TagMapper(ExifDirectoryBase.TAG_USER_COMMENT, DirType.EXIF_IFD0, TagFormat.COMMENT),
ExifInterface.TAG_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_WHITE_BALANCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
ExifInterface.TAG_Y_CB_CR_COEFFICIENTS to TagMapper(ExifDirectoryBase.TAG_YCBCR_COEFFICIENTS, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_WHITE_POINT to TagMapper(ExifDirectoryBase.TAG_WHITE_POINT, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_CB_CR_POSITIONING to TagMapper(ExifDirectoryBase.TAG_YCBCR_POSITIONING, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING to TagMapper(ExifDirectoryBase.TAG_YCBCR_SUBSAMPLING, DirType.EXIF_IFD0, TagFormat.SHORT), ExifInterface.TAG_Y_CB_CR_COEFFICIENTS to TagMapper(ExifDirectoryBase.TAG_YCBCR_COEFFICIENTS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
ExifInterface.TAG_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_Y_RESOLUTION, 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( private val thumbnailTags: Map<String, TagMapper?> = mapOf(
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 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 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( private val gpsTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_GPS_ALTITUDE to TagMapper(GpsDirectory.TAG_ALTITUDE, DirType.GPS, TagFormat.RATIONAL), 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_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_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_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 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_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 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_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 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_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 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_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_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_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_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 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_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 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_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 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_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_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_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_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_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 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_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_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_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 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_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), ExifInterface.TAG_GPS_VERSION_ID to TagMapper(GpsDirectory.TAG_VERSION_ID, DirType.GPS, TagFormat.BYTE),
) )
private val xmpTags: Map<String, TagMapper?> = hashMapOf( private val xmpTags: Map<String, TagMapper?> = mapOf(
ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC
) )
private val rawTags: Map<String, TagMapper?> = hashMapOf( private val rawTags: Map<String, TagMapper?> = mapOf(
// DNG // DNG
ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620 ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620
ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612 ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612
// ORF // ORF
ExifInterface.TAG_ORF_ASPECT_FRAME to TagMapper(OlympusImageProcessingMakernoteDirectory.TagAspectFrame, DirType.OIPM, TagFormat.LONG), // ORF_IMAGE_PROCESSING_TAGS 0x1113 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_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_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 ExifInterface.TAG_ORF_THUMBNAIL_IMAGE to TagMapper(OlympusMakernoteDirectory.TAG_THUMBNAIL_IMAGE, DirType.OM, TagFormat.UNDEFINED), // ORF_MAKER_NOTE_TAGS 0x0100
// RW2 // RW2
ExifInterface.TAG_RW2_ISO to TagMapper(PanasonicRawIFD0Directory.TagIso, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0017 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_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_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_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_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 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) // list of known ExifInterface tags (as of androidx.exifinterface:exifinterface:1.3.0)
// mapped to metadata-extractor tags (as of v2.14.0) // mapped to metadata-extractor tags (as of v2.14.0)
@JvmField
val allTags: Map<String, TagMapper?> = hashMapOf<String, TagMapper?>( val allTags: Map<String, TagMapper?> = hashMapOf<String, TagMapper?>(
).apply { ).apply {
putAll(baseTags) putAll(baseTags)
@ -207,7 +208,6 @@ object ExifInterfaceHelper {
putAll(rawTags) putAll(rawTags)
} }
@JvmStatic
fun describeAll(exif: ExifInterface): Map<String, Map<String, String>> { fun describeAll(exif: ExifInterface): Map<String, Map<String, String>> {
// initialize metadata-extractor directories that we will fill // initialize metadata-extractor directories that we will fill
// by tags converted from the ExifInterface attributes // by tags converted from the ExifInterface attributes
@ -231,7 +231,7 @@ object ExifInterfaceHelper {
for ((exifInterfaceTag, mapper) in tags) { for ((exifInterfaceTag, mapper) in tags) {
if (exif.hasAttribute(exifInterfaceTag)) { if (exif.hasAttribute(exifInterfaceTag)) {
val value: String? = exif.getAttribute(exifInterfaceTag) val value: String? = exif.getAttribute(exifInterfaceTag)
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) { if (value != null && !(value == "0" && isNeverNull(exifInterfaceTag))) {
if (mapper != null) { if (mapper != null) {
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance") val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
val type = mapper.type val type = mapper.type
@ -260,7 +260,7 @@ object ExifInterfaceHelper {
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) { if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
val obj: Any? = when (mapper.format) { val obj: Any? = when (mapper.format) {
TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value
TagFormat.BYTE -> value.toByteArray() TagFormat.BYTE -> exif.getAttributeBytes(exifInterfaceTag)
TagFormat.SHORT -> value.toShortOrNull() TagFormat.SHORT -> value.toShortOrNull()
TagFormat.LONG -> value.toLongOrNull() TagFormat.LONG -> value.toLongOrNull()
TagFormat.RATIONAL -> toRational(value) TagFormat.RATIONAL -> toRational(value)

View file

@ -9,40 +9,38 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
object MediaMetadataRetrieverHelper { object MediaMetadataRetrieverHelper {
@JvmField
val allKeys = hashMapOf( val allKeys = hashMapOf(
MediaMetadataRetriever.METADATA_KEY_ALBUM to "Album", MediaMetadataRetriever.METADATA_KEY_ALBUM to "Album",
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "Album Artist", MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "Album Artist",
MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist", MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist",
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author", MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate", MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate", MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number", MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range", MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard", MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer", MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation", MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer", MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
MediaMetadataRetriever.METADATA_KEY_DATE to "Date", MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "Disc Number", MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "Disc Number",
MediaMetadataRetriever.METADATA_KEY_DURATION to "Duration", MediaMetadataRetriever.METADATA_KEY_DURATION to "Duration",
MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "Exif Length", MediaMetadataRetriever.METADATA_KEY_GENRE to "Genre",
MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "Exif Offset", MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "Has Audio",
MediaMetadataRetriever.METADATA_KEY_GENRE to "Genre", MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "Has Video",
MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "Has Audio", MediaMetadataRetriever.METADATA_KEY_LOCATION to "Location",
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "Has Video", MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIME Type",
MediaMetadataRetriever.METADATA_KEY_LOCATION to "Location", MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIME Type", MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks", MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title", MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height", MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation", MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width", MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply { ).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
putAll(hashMapOf( putAll(
hashMapOf(
MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "Has Image", MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "Has Image",
MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "Image Count", MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "Image Count",
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "Image Height", MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "Image Height",
@ -50,7 +48,16 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "Image Rotation", MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "Image Rotation",
MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "Image Width", MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "Image Width",
MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT to "Video Frame Count", MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT to "Video Frame Count",
)) )
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
putAll(
hashMapOf(
MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "Exif Length",
MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "Exif Offset",
)
)
} }
} }
@ -91,7 +98,7 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels" MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
MediaMetadataRetriever.METADATA_KEY_BITRATE -> { MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
val bitrate = value.toLongOrNull() ?: 0 val bitrate = value.toLongOrNull() ?: 0
if (bitrate > 0) Formatter.formatFileSize(context, bitrate) + "/sec" else null if (bitrate > 0) "${Formatter.formatFileSize(context, bitrate)}/sec" else null
} }
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> { MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
val framerate = value.toDoubleOrNull() ?: 0.0 val framerate = value.toDoubleOrNull() ?: 0.0

View file

@ -19,7 +19,6 @@ object Metadata {
const val DIR_MEDIA = "Media" const val DIR_MEDIA = "Media"
// interpret EXIF code to angle (0, 90, 180 or 270 degrees) // interpret EXIF code to angle (0, 90, 180 or 270 degrees)
@JvmStatic
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) { fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90 ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90
ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_FLIP_VERTICAL -> 180 ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_FLIP_VERTICAL -> 180
@ -28,13 +27,11 @@ object Metadata {
} }
// interpret EXIF code to whether the image is flipped // interpret EXIF code to whether the image is flipped
@JvmStatic
fun isFlippedForExifCode(exifOrientation: Int): Boolean = when (exifOrientation) { fun isFlippedForExifCode(exifOrientation: Int): Boolean = when (exifOrientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL, ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_FLIP_VERTICAL, ExifInterface.ORIENTATION_TRANSPOSE -> true ExifInterface.ORIENTATION_FLIP_HORIZONTAL, ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_FLIP_VERTICAL, ExifInterface.ORIENTATION_TRANSPOSE -> true
else -> false else -> false
} }
@JvmStatic
fun getExifCode(rotationDegrees: Int, isFlipped: Boolean): Int { fun getExifCode(rotationDegrees: Int, isFlipped: Boolean): Int {
return when (rotationDegrees) { return when (rotationDegrees) {
90 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSVERSE else ExifInterface.ORIENTATION_ROTATE_90 90 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSVERSE else ExifInterface.ORIENTATION_ROTATE_90
@ -45,7 +42,6 @@ object Metadata {
} }
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
@JvmStatic
fun parseVideoMetadataDate(metadataDate: String?): Long { fun parseVideoMetadataDate(metadataDate: String?): Long {
var dateString = metadataDate ?: return 0 var dateString = metadataDate ?: return 0
@ -61,7 +57,7 @@ object Metadata {
var timeZone: TimeZone? = null var timeZone: TimeZone? = null
val timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString) val timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString)
if (timeZoneMatcher.find()) { if (timeZoneMatcher.find()) {
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replace("Z".toRegex(), "")) timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z".toRegex(), "")}")
dateString = timeZoneMatcher.replaceAll("") dateString = timeZoneMatcher.replaceAll("")
} }

View file

@ -1,23 +1,13 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.model.provider.FieldMap
class AvesImageEntry(map: Map<String?, Any?>) { class AvesImageEntry(map: FieldMap) {
@JvmField
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI 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 val path = map["path"] as String? // best effort to get local path
@JvmField
val mimeType = map["mimeType"] as String val mimeType = map["mimeType"] as String
@JvmField
val width = map["width"] as Int val width = map["width"] as Int
@JvmField
val height = map["height"] as Int val height = map["height"] as Int
@JvmField
val rotationDegrees = map["rotationDegrees"] as Int val rotationDegrees = map["rotationDegrees"] as Int
} }

View file

@ -24,6 +24,7 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeLong
import deckers.thibault.aves.model.provider.FieldMap
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.IOException import java.io.IOException
@ -48,7 +49,7 @@ class SourceImageEntry {
this.sourceMimeType = sourceMimeType this.sourceMimeType = sourceMimeType
} }
constructor(map: Map<String, Any?>) { constructor(map: FieldMap) {
uri = Uri.parse(map["uri"] as String) uri = Uri.parse(map["uri"] as String)
path = map["path"] as String? path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String sourceMimeType = map["sourceMimeType"] as String
@ -69,21 +70,21 @@ class SourceImageEntry {
this.dateModifiedSecs = dateModifiedSecs this.dateModifiedSecs = dateModifiedSecs
} }
fun toMap(): Map<String, Any?> { fun toMap(): FieldMap {
return hashMapOf( return hashMapOf(
"uri" to uri.toString(), "uri" to uri.toString(),
"path" to path, "path" to path,
"sourceMimeType" to sourceMimeType, "sourceMimeType" to sourceMimeType,
"width" to width, "width" to width,
"height" to height, "height" to height,
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0), "sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
"sizeBytes" to sizeBytes, "sizeBytes" to sizeBytes,
"title" to title, "title" to title,
"dateModifiedSecs" to dateModifiedSecs, "dateModifiedSecs" to dateModifiedSecs,
"sourceDateTakenMillis" to sourceDateTakenMillis, "sourceDateTakenMillis" to sourceDateTakenMillis,
"durationMillis" to durationMillis, "durationMillis" to durationMillis,
// only for map export // only for map export
"contentId" to contentId, "contentId" to contentId,
) )
} }

View file

@ -0,0 +1,45 @@
package deckers.thibault.aves.model.provider
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import deckers.thibault.aves.model.SourceImageEntry
internal class ContentImageProvider : ImageProvider() {
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
if (mimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri"))
return
}
val map = hashMapOf<String, Any?>(
"uri" to uri.toString(),
"sourceMimeType" to mimeType,
)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return
}
val entry = SourceImageEntry(map).fillPreCatalogMetadata(context)
if (entry.isSized || entry.isSvg) {
callback.onSuccess(entry.toMap())
} else {
callback.onFailure(Exception("entry has no size"))
}
}
companion object {
private val projection = arrayOf(
MediaStore.MediaColumns.SIZE,
MediaStore.MediaColumns.DISPLAY_NAME
)
}
}

View file

@ -0,0 +1,36 @@
package deckers.thibault.aves.model.provider
import android.content.Context
import android.net.Uri
import deckers.thibault.aves.model.SourceImageEntry
import java.io.File
internal class FileImageProvider : ImageProvider() {
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
if (mimeType == null) {
callback.onFailure(Exception("MIME type is null for uri=$uri"))
return
}
val entry = SourceImageEntry(uri, mimeType)
val path = uri.path
if (path != null) {
try {
val file = File(path)
if (file.exists()) {
entry.initFromFile(path, file.name, file.length(), file.lastModified() / 1000)
}
} catch (e: SecurityException) {
callback.onFailure(e)
}
}
entry.fillPreCatalogMetadata(context)
if (entry.isSized || entry.isSvg) {
callback.onSuccess(entry.toMap())
} else {
callback.onFailure(Exception("entry has no size"))
}
}
}

View file

@ -0,0 +1,194 @@
package deckers.thibault.aves.model.provider
import android.content.ContentUris
import android.content.Context
import android.media.MediaScannerConnection
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.commonsware.cwac.document.DocumentFileCompat
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import deckers.thibault.aves.model.AvesImageEntry
import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.copyFileToTemp
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.*
abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}
open fun delete(context: Context, uri: Uri, path: String?): ListenableFuture<Any?> {
return Futures.immediateFailedFuture(UnsupportedOperationException())
}
open fun moveMultiple(context: Context, copy: Boolean, destinationDir: String, entries: List<AvesImageEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException())
}
fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFilename)
if (oldFile == newFile) {
Log.w(LOG_TAG, "new name and old name are the same, path=$oldPath")
callback.onSuccess(HashMap())
return
}
val df = getDocumentFile(context, oldPath, oldMediaUri)
try {
val renamed = df != null && df.renameTo(newFilename)
if (!renamed) {
callback.onFailure(Exception("failed to rename entry at path=$oldPath"))
return
}
} catch (e: FileNotFoundException) {
callback.onFailure(e)
return
}
MediaScannerConnection.scanFile(context, arrayOf(oldPath), arrayOf(mimeType), null)
scanNewPath(context, newFile.path, mimeType, callback)
}
fun changeOrientation(context: Context, path: String, uri: Uri, mimeType: String, op: ExifOrientationOp, callback: ImageOpCallback) {
if (!canEditExif(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
return
}
val originalDocumentFile = getDocumentFile(context, path, uri)
if (originalDocumentFile == null) {
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
return
}
// copy original file to a temporary file for editing
val editablePath = copyFileToTemp(originalDocumentFile, path)
if (editablePath == null) {
callback.onFailure(Exception("failed to create a temporary file for path=$path"))
return
}
val newFields = HashMap<String, Any?>()
try {
val exif = ExifInterface(editablePath)
// 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
val currentOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
if (currentOrientation == ExifInterface.ORIENTATION_UNDEFINED) {
exif.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString())
}
when (op) {
ExifOrientationOp.ROTATE_CW -> exif.rotate(90)
ExifOrientationOp.ROTATE_CCW -> exif.rotate(-90)
ExifOrientationOp.FLIP -> exif.flipHorizontally()
}
exif.saveAttributes()
// copy the edited temporary file back to the original
DocumentFileCompat.fromFile(File(editablePath)).copyTo(originalDocumentFile)
newFields["rotationDegrees"] = exif.rotationDegrees
newFields["isFlipped"] = exif.isFlipped
} catch (e: IOException) {
callback.onFailure(e)
return
}
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
val projection = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)
try {
val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
callback.onSuccess(newFields)
}
}
// support for writing EXIF
// as of androidx.exifinterface:exifinterface:1.3.0
private fun canEditExif(mimeType: String): Boolean {
return when (mimeType) {
"image/jpeg", "image/png", "image/webp" -> true
else -> false
}
}
protected fun scanNewPath(context: Context, path: String, mimeType: String, callback: ImageOpCallback) {
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, newUri: Uri? ->
var contentId: Long = 0
var contentUri: Uri? = null
if (newUri != null) {
// `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 (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId)
}
}
if (contentUri == null) {
callback.onFailure(Exception("failed to get content URI of item at path=$path"))
return@scanFile
}
val newFields = HashMap<String, Any?>()
// we retrieve updated fields as the renamed/moved file became a new entry in the Media Store
val projection = arrayOf(
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.TITLE,
)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
newFields["uri"] = contentUri.toString()
newFields["contentId"] = contentId
newFields["path"] = path
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME).let { if (it != -1) newFields["displayName"] = cursor.getString(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.TITLE).let { if (it != -1) newFields["title"] = cursor.getString(it) }
cursor.close()
}
} catch (e: Exception) {
callback.onFailure(e)
return@scanFile
}
if (newFields.isEmpty()) {
callback.onFailure(Exception("failed to get item details from provider at contentUri=$contentUri"))
} else {
callback.onSuccess(newFields)
}
}
}
interface ImageOpCallback {
fun onSuccess(fields: FieldMap)
fun onFailure(throwable: Throwable)
}
companion object {
private val LOG_TAG = createTag(ImageProvider::class.java)
}
}
typealias FieldMap = MutableMap<String, Any?>

View file

@ -0,0 +1,23 @@
package deckers.thibault.aves.model.provider
import android.content.ContentResolver
import android.net.Uri
import android.provider.MediaStore
import java.util.*
object ImageProviderFactory {
fun getProvider(uri: Uri): ImageProvider? {
return when (uri.scheme?.toLowerCase(Locale.ROOT)) {
ContentResolver.SCHEME_CONTENT -> {
// a URI's authority is [userinfo@]host[:port]
// but we only want the host when comparing to Media Store's "authority"
return when (uri.host?.toLowerCase(Locale.ROOT)) {
MediaStore.AUTHORITY -> MediaStoreImageProvider()
else -> ContentImageProvider()
}
}
ContentResolver.SCHEME_FILE -> FileImageProvider()
else -> null
}
}
}

View file

@ -0,0 +1,370 @@
package deckers.thibault.aves.model.provider
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import com.commonsware.cwac.document.DocumentFileCompat
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import deckers.thibault.aves.model.AvesImageEntry
import deckers.thibault.aves.model.SourceImageEntry
import deckers.thibault.aves.utils.LogUtils.createTag
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import java.io.File
import java.io.FileNotFoundException
import java.util.*
import java.util.concurrent.ExecutionException
class MediaStoreImageProvider : ImageProvider() {
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedSecs
}
fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION)
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
}
override fun fetchSingle(context: Context, uri: Uri, mimeType: String?, callback: ImageOpCallback) {
val id = ContentUris.parseId(uri)
val onSuccess = fun(entry: FieldMap) {
entry["uri"] = uri.toString()
callback.onSuccess(entry)
}
val alwaysValid = { _: Int, _: Int -> true }
if (mimeType == null || isImage(mimeType)) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, IMAGE_PROJECTION) > 0) return
}
if (mimeType == null || isVideo(mimeType)) {
val contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, id)
if (fetchFrom(context, alwaysValid, onSuccess, contentUri, VIDEO_PROJECTION) > 0) return
}
callback.onFailure(Exception("failed to fetch entry at uri=$uri"))
}
fun getObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
val current = arrayListOf<Int>().apply {
addAll(getContentIdList(context, IMAGE_CONTENT_URI))
addAll(getContentIdList(context, VIDEO_CONTENT_URI))
}
return knownContentIds.filter { id: Int -> !current.contains(id) }.toList()
}
private fun getContentIdList(context: Context, contentUri: Uri): List<Int> {
val foundContentIds = ArrayList<Int>()
val projection = arrayOf(MediaStore.MediaColumns._ID)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
while (cursor.moveToNext()) {
foundContentIds.add(cursor.getInt(idColumn))
}
cursor.close()
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get content IDs for contentUri=$contentUri", e)
}
return foundContentIds
}
private fun fetchFrom(
context: Context,
isValidEntry: NewEntryChecker,
handleNewEntry: NewEntryHandler,
contentUri: Uri,
projection: Array<String>,
): Int {
var newEntryCount = 0
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy)
if (cursor != null) {
// image & video
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE)
val widthColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
val dateTakenColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)
// image & video for API >= Q, only for images for API < Q
val orientationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.ORIENTATION)
// video only
val durationColumn = cursor.getColumnIndex(MediaStore.MediaColumns.DURATION)
val needDuration = projection.contentEquals(VIDEO_PROJECTION)
while (cursor.moveToNext()) {
val contentId = cursor.getInt(idColumn)
val dateModifiedSecs = cursor.getInt(dateModifiedColumn)
if (isValidEntry(contentId, dateModifiedSecs)) {
// building `itemUri` this way is fine if `contentUri` does not already contain the ID
val itemUri = ContentUris.withAppendedId(contentUri, contentId.toLong())
val mimeType = cursor.getString(mimeTypeColumn)
val width = cursor.getInt(widthColumn)
val height = cursor.getInt(heightColumn)
val durationMillis = if (durationColumn != -1) cursor.getLong(durationColumn) else 0L
var entryMap: FieldMap = hashMapOf(
"uri" to itemUri.toString(),
"path" to cursor.getString(pathColumn),
"sourceMimeType" to mimeType,
"width" to width,
"height" to height,
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
"sizeBytes" to cursor.getLong(sizeColumn),
"title" to cursor.getString(titleColumn),
"dateModifiedSecs" to dateModifiedSecs,
"sourceDateTakenMillis" to cursor.getLong(dateTakenColumn),
"durationMillis" to durationMillis,
// only for map export
"contentId" to contentId,
)
if ((width <= 0 || height <= 0) && needSize(mimeType)
|| durationMillis == 0L && needDuration
) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
val entry = SourceImageEntry(entryMap).fillPreCatalogMetadata(context)
entryMap = entry.toMap()
}
handleNewEntry(entryMap)
// TODO TLAD is this necessary?
if (newEntryCount % 30 == 0) {
Thread.sleep(10)
}
newEntryCount++
}
}
cursor.close()
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get entries", e)
}
return newEntryCount
}
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI
override fun delete(context: Context, uri: Uri, path: String?): ListenableFuture<Any?> {
val future = SettableFuture.create<Any?>()
if (path == null) {
future.setException(Exception("failed to delete file because path is null"))
return future
}
if (requireAccessPermission(context, path)) {
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store
// but it doesn't delete the file, even if the app has the permission
try {
val df = getDocumentFile(context, path, uri)
if (df != null && df.delete()) {
future.set(null)
} else {
future.setException(Exception("failed to delete file with df=$df"))
}
} catch (e: FileNotFoundException) {
future.setException(e)
}
return future
}
try {
if (context.contentResolver.delete(uri, null, null) > 0) {
future.set(null)
} else {
future.setException(Exception("failed to delete row from content provider"))
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to delete entry", e)
future.setException(e)
}
return future
}
override fun moveMultiple(
context: Context,
copy: Boolean,
destinationDir: String,
entries: List<AvesImageEntry>,
callback: ImageOpCallback,
) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
if (destinationDirDocFile == null) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
return
}
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
val mimeType = entry.mimeType
val result = hashMapOf<String, Any?>(
"uri" to sourceUri.toString(),
"success" to false,
)
if (sourcePath != null) {
// on API 30 we cannot get access granted directly to a volume root from its document tree,
// but it is still less constraining to use tree document files than to rely on the Media Store
//
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name
//
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
// with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try {
val newFieldsFuture = moveSingleByTreeDocAndScan(
context, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy,
)
result["newFields"] = newFieldsFuture.get()
result["success"] = true
} catch (e: ExecutionException) {
Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e)
} catch (e: InterruptedException) {
Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e)
}
}
callback.onSuccess(result)
}
}
private fun moveSingleByTreeDocAndScan(
context: Context,
sourcePath: String,
sourceUri: Uri,
destinationDir: String,
destinationDirDocFile: DocumentFileCompat,
mimeType: String,
copy: Boolean,
): ListenableFuture<FieldMap> {
val future = SettableFuture.create<FieldMap>()
try {
val sourceFile = File(sourcePath)
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
if (sourceDir == destinationDir) {
if (copy) {
future.setException(Exception("file at path=$sourcePath is already in destination directory"))
} else {
future.set(HashMap<String, Any?>())
}
} else {
val sourceFileName = sourceFile.name
val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
val source = DocumentFileCompat.fromSingleUri(context, sourceUri)
source.copyTo(destinationDocFile)
// the source file name and the created document file name can be different when:
// - a file with the same name already exists, so the name gets a suffix like ` (1)`
// - the original extension does not match the extension added by the underlying provider
val fileName = destinationDocFile.name
val destinationFullPath = destinationDir + fileName
var deletedSource = false
if (!copy) {
// delete original entry
try {
delete(context, sourceUri, sourcePath).get()
deletedSource = true
} catch (e: ExecutionException) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
} catch (e: InterruptedException) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
scanNewPath(context, destinationFullPath, mimeType, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
fields["deletedSource"] = deletedSource
future.set(fields)
}
override fun onFailure(throwable: Throwable) {
future.setException(throwable)
}
})
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to ${(if (copy) "copy" else "move")} entry", e)
future.setException(e)
}
return future
}
companion object {
private val LOG_TAG = createTag(MediaStoreImageProvider::class.java)
private val IMAGE_CONTENT_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
private val VIDEO_CONTENT_URI = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
private val BASE_PROJECTION = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.MIME_TYPE,
MediaStore.MediaColumns.SIZE, // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`?
MediaStore.MediaColumns.TITLE,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DATE_MODIFIED
)
private val IMAGE_PROJECTION = arrayOf(
*BASE_PROJECTION,
// uses `MediaStore.Images.Media` instead of `MediaStore.MediaColumns` for APIs < Q
MediaStore.Images.Media.DATE_TAKEN,
MediaStore.Images.Media.ORIENTATION
)
private val VIDEO_PROJECTION = arrayOf(
*BASE_PROJECTION,
// uses `MediaStore.Video.Media` instead of `MediaStore.MediaColumns` for APIs < Q
MediaStore.Video.Media.DATE_TAKEN,
MediaStore.Video.Media.DURATION,
*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(
MediaStore.Video.Media.ORIENTATION
) else emptyArray()
)
}
}
typealias NewEntryHandler = (entry: FieldMap) -> Unit
private typealias NewEntryChecker = (contentId: Int, dateModifiedSecs: Int) -> Boolean

View file

@ -7,7 +7,6 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode import deckers.thibault.aves.metadata.Metadata.getExifCode
object BitmapUtils { object BitmapUtils {
@JvmStatic
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? { fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap if (rotationDegrees == 0 && !isFlipped) return bitmap
@ -15,12 +14,10 @@ object BitmapUtils {
return TransformationUtils.rotateImageExif(getBitmapPool(context), bitmap, exifOrientation) return TransformationUtils.rotateImageExif(getBitmapPool(context), bitmap, exifOrientation)
} }
@JvmStatic
fun centerSquareCrop(context: Context, bitmap: Bitmap?, size: Int): Bitmap? { fun centerSquareCrop(context: Context, bitmap: Bitmap?, size: Int): Bitmap? {
bitmap ?: return bitmap bitmap ?: return bitmap
return TransformationUtils.centerCrop(getBitmapPool(context), bitmap, size, size) return TransformationUtils.centerCrop(getBitmapPool(context), bitmap, size, size)
} }
@JvmStatic
fun getBitmapPool(context: Context) = Glide.get(context).bitmapPool fun getBitmapPool(context: Context) = Glide.get(context).bitmapPool
} }

View file

@ -7,7 +7,6 @@ object LogUtils {
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.") private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
// create an Android logger friendly log tag for the specified class // create an Android logger friendly log tag for the specified class
@JvmStatic
fun createTag(clazz: Class<*>): String { fun createTag(clazz: Class<*>): String {
// shorten class name to "a.b.CccDdd" // shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.") var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")

View file

@ -26,13 +26,10 @@ object MimeTypes {
private const val MP2T = "video/mp2t" private const val MP2T = "video/mp2t"
private const val WEBM = "video/webm" private const val WEBM = "video/webm"
@JvmStatic
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE) fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
@JvmStatic
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO) fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
@JvmStatic
// returns whether the specified MIME type represents // returns whether the specified MIME type represents
// a raster image format that allows an alpha channel // a raster image format that allows an alpha channel
fun canHaveAlpha(mimeType: String?) = when (mimeType) { fun canHaveAlpha(mimeType: String?) = when (mimeType) {
@ -41,7 +38,6 @@ object MimeTypes {
} }
// as of Flutter v1.22.0 // as of Flutter v1.22.0
@JvmStatic
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) { fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false) PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
@ -49,7 +45,6 @@ object MimeTypes {
} }
// as of metadata-extractor v2.14.0 // as of metadata-extractor v2.14.0
@JvmStatic
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) { fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
WBMP, MP2T, WEBM -> false WBMP, MP2T, WEBM -> false
else -> true else -> true
@ -59,7 +54,6 @@ object MimeTypes {
// but we need to rotate the decoded bitmap for the other formats // but we need to rotate the decoded bitmap for the other formats
// maybe related to ExifInterface version used by Glide: // maybe related to ExifInterface version used by Glide:
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21 // https://github.com/bumptech/glide/blob/master/gradle.properties#L21
@JvmStatic
fun needRotationAfterGlide(mimeType: String) = when (mimeType) { fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
DNG, HEIC, HEIF, PNG, WEBP -> true DNG, HEIC, HEIF, PNG, WEBP -> true
else -> false else -> false
@ -68,7 +62,6 @@ object MimeTypes {
// Thumbnails obtained from the Media Store are automatically rotated // Thumbnails obtained from the Media Store are automatically rotated
// according to EXIF orientation when decoding images of known formats // according to EXIF orientation when decoding images of known formats
// but we need to rotate the decoded bitmap for the other formats // but we need to rotate the decoded bitmap for the other formats
@JvmStatic
fun needRotationAfterContentResolverThumbnail(mimeType: String) = when (mimeType) { fun needRotationAfterContentResolverThumbnail(mimeType: String) = when (mimeType) {
DNG, PNG -> true DNG, PNG -> true
else -> false else -> false

View file

@ -22,7 +22,6 @@ object PermissionManager {
// permission request code to pending runnable // permission request code to pending runnable
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>() private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
@JvmStatic
fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) { fun requestVolumeAccess(activity: Activity, path: String, onGranted: () -> Unit, onDenied: () -> Unit) {
Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path") Log.i(LOG_TAG, "request user to select and grant access permission to volume=$path")
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied) pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
@ -47,12 +46,10 @@ object PermissionManager {
(if (treeUri != null) handler.onGranted else handler.onDenied)() (if (treeUri != null) handler.onGranted else handler.onDenied)()
} }
@JvmStatic
fun getGrantedDirForPath(context: Context, anyPath: String): String? { fun getGrantedDirForPath(context: Context, anyPath: String): String? {
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
} }
@JvmStatic
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> { fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
val accessibleDirs = getAccessibleDirs(context) val accessibleDirs = getAccessibleDirs(context)
@ -103,16 +100,15 @@ object PermissionManager {
return inaccessibleDirs return inaccessibleDirs
} }
@JvmStatic fun revokeDirectoryAccess(context: Context, path: String): Boolean {
fun revokeDirectoryAccess(context: Context, path: String) { return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags) context.contentResolver.releasePersistableUriPermission(it, flags)
} true
} ?: false
} }
// returns paths matching URIs granted by the user // returns paths matching URIs granted by the user
@JvmStatic
fun getGrantedDirs(context: Context): Set<String> { fun getGrantedDirs(context: Context): Set<String> {
val grantedDirs = HashSet<String>() val grantedDirs = HashSet<String>()
for (uriPermission in context.contentResolver.persistedUriPermissions) { for (uriPermission in context.contentResolver.persistedUriPermissions) {
@ -127,7 +123,7 @@ object PermissionManager {
val accessibleDirs = HashSet(getGrantedDirs(context)) val accessibleDirs = HashSet(getGrantedDirs(context))
// from Android R, we no longer have access permission by default on primary volume // from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
accessibleDirs.add(StorageUtils.primaryVolumePath) accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
} }
Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs") Log.d(LOG_TAG, "getAccessibleDirs accessibleDirs=$accessibleDirs")
return accessibleDirs return accessibleDirs

View file

@ -6,7 +6,6 @@ import android.content.Context
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
@ -36,15 +35,13 @@ object StorageUtils {
// primary volume path, with trailing "/" // primary volume path, with trailing "/"
private var mPrimaryVolumePath: String? = null private var mPrimaryVolumePath: String? = null
val primaryVolumePath: String fun getPrimaryVolumePath(context: Context): String {
get() { if (mPrimaryVolumePath == null) {
if (mPrimaryVolumePath == null) { mPrimaryVolumePath = findPrimaryVolumePath(context)
mPrimaryVolumePath = findPrimaryVolumePath()
}
return mPrimaryVolumePath!!
} }
return mPrimaryVolumePath!!
}
@JvmStatic
fun getVolumePaths(context: Context): Array<String> { fun getVolumePaths(context: Context): Array<String> {
if (mStorageVolumePaths == null) { if (mStorageVolumePaths == null) {
mStorageVolumePaths = findVolumePaths(context) mStorageVolumePaths = findVolumePaths(context)
@ -52,7 +49,6 @@ object StorageUtils {
return mStorageVolumePaths!! return mStorageVolumePaths!!
} }
@JvmStatic
fun getVolumePath(context: Context, anyPath: String): String? { fun getVolumePath(context: Context, anyPath: String): String? {
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) } return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
} }
@ -76,8 +72,17 @@ object StorageUtils {
return pathSteps.iterator() return pathSteps.iterator()
} }
private fun findPrimaryVolumePath(): String { private fun findPrimaryVolumePath(context: Context): String? {
return ensureTrailingSeparator(Environment.getExternalStorageDirectory().absolutePath) // we want:
// /storage/emulated/0/
// `Environment.getExternalStorageDirectory()` (deprecated) yields:
// /storage/emulated/0
// `context.getExternalFilesDir(null)` yields:
// /storage/emulated/0/Android/data/{package_name}/files
return context.getExternalFilesDir(null)?.let {
val appSpecificPath = it.absolutePath
return appSpecificPath.substring(0, appSpecificPath.indexOf("Android/data"))
}
} }
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
@ -126,10 +131,10 @@ object StorageUtils {
} }
} else { } else {
// Device has emulated storage; external storage paths should have userId burned into them. // Device has emulated storage; external storage paths should have userId burned into them.
val path = Environment.getExternalStorageDirectory().absolutePath // /storage/emulated/[0,1,2,...]/
val rawUserId = path.split(File.separator).lastOrNull()?.takeIf { TextUtils.isDigitsOnly(it) } ?: "" val path = getPrimaryVolumePath(context)
// /storage/emulated/0[1,2,...] val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
if (TextUtils.isEmpty(rawUserId)) { if (rawUserId.isEmpty()) {
paths.add(rawEmulatedStorageTarget) paths.add(rawEmulatedStorageTarget)
} else { } else {
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId) paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
@ -145,30 +150,29 @@ object StorageUtils {
} }
// return physicalPaths based on phone model // return physicalPaths based on phone model
private val physicalPaths: Array<String> @SuppressLint("SdCardPath")
@SuppressLint("SdCardPath") private val physicalPaths = arrayOf(
get() = arrayOf( "/storage/sdcard0",
"/storage/sdcard0", "/storage/sdcard1", //Motorola Xoom
"/storage/sdcard1", //Motorola Xoom "/storage/extsdcard", //Samsung SGS3
"/storage/extsdcard", //Samsung SGS3 "/storage/sdcard0/external_sdcard", //User request
"/storage/sdcard0/external_sdcard", //User request "/mnt/extsdcard",
"/mnt/extsdcard", "/mnt/sdcard/external_sd", //Samsung galaxy family
"/mnt/sdcard/external_sd", //Samsung galaxy family "/mnt/external_sd",
"/mnt/external_sd", "/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3 "/removable/microsd", //Asus transformer prime
"/removable/microsd", //Asus transformer prime "/mnt/emmc",
"/mnt/emmc", "/storage/external_SD", //LG
"/storage/external_SD", //LG "/storage/ext_sd", //HTC One Max
"/storage/ext_sd", //HTC One Max "/storage/removable/sdcard1", //Sony Xperia Z1
"/storage/removable/sdcard1", //Sony Xperia Z1 "/data/sdext",
"/data/sdext", "/data/sdext2",
"/data/sdext2", "/data/sdext3",
"/data/sdext3", "/data/sdext4",
"/data/sdext4", "/sdcard1", //Sony Xperia Z
"/sdcard1", //Sony Xperia Z "/sdcard2", //HTC One M8s
"/sdcard2", //HTC One M8s "/storage/microsd" //ASUS ZenFone 2
"/storage/microsd" //ASUS ZenFone 2 )
)
/** /**
* Volume tree URIs * Volume tree URIs
@ -194,7 +198,7 @@ object StorageUtils {
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? { private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") { if (uuid == "primary") {
return primaryVolumePath return getPrimaryVolumePath(context)
} }
val sm = context.getSystemService(StorageManager::class.java) val sm = context.getSystemService(StorageManager::class.java)
if (sm != null) { if (sm != null) {
@ -255,30 +259,33 @@ object StorageUtils {
* Document files * Document files
*/ */
@JvmStatic
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? { fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
if (requireAccessPermission(anyPath)) { try {
// need a document URI (not a media content URI) to open a `DocumentFile` output stream if (requireAccessPermission(context, anyPath)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // need a document URI (not a media content URI) to open a `DocumentFile` output stream
// cleanest API to get it if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
val docUri = MediaStore.getDocumentUri(context, mediaUri) // cleanest API to get it
if (docUri != null) { val docUri = MediaStore.getDocumentUri(context, mediaUri)
return DocumentFileCompat.fromSingleUri(context, docUri) if (docUri != null) {
return DocumentFileCompat.fromSingleUri(context, docUri)
}
} }
// fallback for older APIs
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
} }
// fallback for older APIs // good old `File`
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) } return DocumentFileCompat.fromFile(File(anyPath))
} catch (e: SecurityException) {
Log.w(LOG_TAG, "failed to get document file from mediaUri=$mediaUri", e)
} }
// good old `File` return null
return DocumentFileCompat.fromFile(File(anyPath))
} }
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise) // returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
// returns null if directory does not exist and could not be created // returns null if directory does not exist and could not be created
@JvmStatic
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
val cleanDirPath = ensureTrailingSeparator(dirPath) val cleanDirPath = ensureTrailingSeparator(dirPath)
return if (requireAccessPermission(cleanDirPath)) { return if (requireAccessPermission(context, cleanDirPath)) {
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null
@ -311,7 +318,6 @@ object StorageUtils {
} }
} }
@JvmStatic
fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? { fun copyFileToTemp(documentFile: DocumentFileCompat, path: String): String? {
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString()) val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
try { try {
@ -351,13 +357,12 @@ object StorageUtils {
* Misc * Misc
*/ */
@JvmStatic fun requireAccessPermission(context: Context, anyPath: String): Boolean {
fun requireAccessPermission(anyPath: String): Boolean {
// on Android R, we should always require access permission, even on primary volume // on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
return true return true
} }
val onPrimaryVolume = anyPath.startsWith(primaryVolumePath) val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
return !onPrimaryVolume return !onPrimaryVolume
} }
@ -380,10 +385,12 @@ object StorageUtils {
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {
Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri") Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri")
null null
} catch (e: SecurityException) {
Log.w(LOG_TAG, "failed to open file at uri=$effectiveUri", e)
null
} }
} }
@JvmStatic
fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? { fun openMetadataRetriever(context: Context, uri: Uri): MediaMetadataRetriever? {
var effectiveUri = uri var effectiveUri = uri
// we get a permission denial if we require original from a provider other than the media store // we get a permission denial if we require original from a provider other than the media store
@ -403,7 +410,7 @@ object StorageUtils {
// convenience methods // convenience methods
private fun ensureTrailingSeparator(dirPath: String): String { fun ensureTrailingSeparator(dirPath: String): String {
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
} }
@ -411,7 +418,7 @@ object StorageUtils {
class PathSegments(context: Context, fullPath: String) { class PathSegments(context: Context, fullPath: String) {
var volumePath: String? = null // `volumePath` with trailing "/" var volumePath: String? = null // `volumePath` with trailing "/"
var relativeDir: String? = null // `relativeDir` with trailing "/" var relativeDir: String? = null // `relativeDir` with trailing "/"
var filename: String? = null // null for directories private var filename: String? = null // null for directories
init { init {
volumePath = getVolumePath(context, fullPath) volumePath = getVolumePath(context, fullPath)

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

View file

@ -1,12 +1,13 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.4.10' ext.kotlin_version = '1.4.10'
repositories { repositories {
google() google()
jcenter() jcenter()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.6.3' // do not upgrade to 4+ until this is fixed: https://github.com/flutter/flutter/issues/58247 // TODO TLAD upgrade AGP to 4+ when this is fixed: https://github.com/flutter/flutter/issues/58247
classpath 'com.android.tools.build:gradle:3.6.4'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.4' classpath 'com.google.gms:google-services:4.3.4'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0'

View file

@ -1,4 +1,18 @@
org.gradle.jvmargs=-Xmx1536M # Project-wide Gradle settings.
android.enableR8=true # IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
android.enableR8=true

View file

@ -1,6 +1,6 @@
#Tue Apr 21 13:20:37 KST 2020 #Thu Oct 22 10:54:33 KST 2020
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

View file

@ -82,13 +82,13 @@ class _AvesAppState extends State<AvesApp> {
), ),
); );
Widget get firstPage => settings.hasAcceptedTerms ? HomePage() : WelcomePage(); Widget getFirstPage({Map intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : WelcomePage();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appSetup = _setup(); _appSetup = _setup();
_newIntentChannel.receiveBroadcastStream().listen((_) => _onNewIntent()); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
} }
Future<void> _setup() async { Future<void> _setup() async {
@ -109,11 +109,16 @@ class _AvesAppState extends State<AvesApp> {
await settings.initCrashlytics(); await settings.initCrashlytics();
} }
void _onNewIntent() { void _onNewIntent(Map intentData) {
debugPrint('$runtimeType onNewIntent with intentData=$intentData');
// do not reset when relaunching the app
if (AvesApp.mode == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
FirebaseCrashlytics.instance.log('New intent'); FirebaseCrashlytics.instance.log('New intent');
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute( _navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(
settings: RouteSettings(name: HomePage.routeName), settings: RouteSettings(name: HomePage.routeName),
builder: (_) => firstPage, builder: (_) => getFirstPage(intentData: intentData),
)); ));
} }
@ -125,7 +130,7 @@ class _AvesAppState extends State<AvesApp> {
future: _appSetup, future: _appSetup,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) {
return firstPage; return getFirstPage();
} }
return Scaffold( return Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(), body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),

View file

@ -56,6 +56,8 @@ class ImageEntry {
this.dateModifiedSecs = dateModifiedSecs; this.dateModifiedSecs = dateModifiedSecs;
} }
bool get canDecode => !MimeTypes.undecodable.contains(mimeType);
ImageEntry copyWith({ ImageEntry copyWith({
@required String uri, @required String uri,
@required String path, @required String path,

View file

@ -9,6 +9,9 @@ class MimeTypes {
static const String svg = 'image/svg+xml'; static const String svg = 'image/svg+xml';
static const String webp = 'image/webp'; static const String webp = 'image/webp';
static const String tiff = 'image/tiff';
static const String psd = 'image/vnd.adobe.photoshop';
static const String arw = 'image/x-sony-arw'; static const String arw = 'image/x-sony-arw';
static const String cr2 = 'image/x-canon-cr2'; static const String cr2 = 'image/x-canon-cr2';
static const String crw = 'image/x-canon-crw'; static const String crw = 'image/x-canon-crw';
@ -38,4 +41,5 @@ class MimeTypes {
// groups // 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]; 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];
static const List<String> undecodable = [crw, psd, tiff]; // TODO TLAD make it dynamic if it depends on OS/lib versions
} }

View file

@ -136,13 +136,15 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails)); await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
} else { } else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async { await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
final sourceUri = movedOp.uri;
final newFields = movedOp.newFields; final newFields = movedOp.newFields;
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null); if (newFields.isNotEmpty) {
if (entry != null) { final sourceUri = movedOp.uri;
fromAlbums.add(entry.directory); final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
movedEntries.add(entry); if (entry != null) {
await moveEntry(entry, newFields); fromAlbums.add(entry.directory);
movedEntries.add(entry);
await moveEntry(entry, newFields);
}
} }
}); });
} }

View file

@ -41,9 +41,9 @@ class AndroidAppService {
return {}; return {};
} }
static Future<void> edit(String uri, String mimeType) async { static Future<bool> edit(String uri, String mimeType) async {
try { try {
await platform.invokeMethod('edit', <String, dynamic>{ return await platform.invokeMethod('edit', <String, dynamic>{
'title': 'Edit with:', 'title': 'Edit with:',
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
@ -51,11 +51,12 @@ class AndroidAppService {
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return false;
} }
static Future<void> open(String uri, String mimeType) async { static Future<bool> open(String uri, String mimeType) async {
try { try {
await platform.invokeMethod('open', <String, dynamic>{ return await platform.invokeMethod('open', <String, dynamic>{
'title': 'Open with:', 'title': 'Open with:',
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
@ -63,22 +64,23 @@ class AndroidAppService {
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return false;
} }
static Future<void> openMap(String geoUri) async { static Future<bool> openMap(String geoUri) async {
if (geoUri == null) return;
try { try {
await platform.invokeMethod('openMap', <String, dynamic>{ return await platform.invokeMethod('openMap', <String, dynamic>{
'geoUri': geoUri, 'geoUri': geoUri,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return false;
} }
static Future<void> setAs(String uri, String mimeType) async { static Future<bool> setAs(String uri, String mimeType) async {
try { try {
await platform.invokeMethod('setAs', <String, dynamic>{ return await platform.invokeMethod('setAs', <String, dynamic>{
'title': 'Set as:', 'title': 'Set as:',
'uri': uri, 'uri': uri,
'mimeType': mimeType, 'mimeType': mimeType,
@ -86,19 +88,21 @@ class AndroidAppService {
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return false;
} }
static Future<void> share(Iterable<ImageEntry> entries) async { static Future<bool> share(Iterable<ImageEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps // loosen mime type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); final urisByMimeType = groupBy<ImageEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
try { try {
await platform.invokeMethod('share', <String, dynamic>{ return await platform.invokeMethod('share', <String, dynamic>{
'title': 'Share via:', 'title': 'Share via:',
'urisByMimeType': urisByMimeType, 'urisByMimeType': urisByMimeType,
}); });
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}'); debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}');
} }
return false;
} }
} }

View file

@ -18,6 +18,8 @@ class Constants {
offset: Offset(0.5, 1.0), offset: Offset(0.5, 1.0),
); );
static const String unknown = 'unknown';
static const pointNemo = Tuple2(-48.876667, -123.393333); static const pointNemo = Tuple2(-48.876667, -123.393333);
static const int infoGroupMaxValueLength = 140; static const int infoGroupMaxValueLength = 140;

View file

@ -0,0 +1,24 @@
import 'package:aves/widgets/common/icons.dart';
import 'package:flutter/material.dart';
class ErrorThumbnail extends StatelessWidget {
final double extent;
final String tooltip;
const ErrorThumbnail({@required this.extent, @required this.tooltip});
@override
Widget build(BuildContext context) {
return Center(
child: Tooltip(
message: tooltip,
preferBelow: false,
child: Icon(
AIcons.error,
size: extent / 2,
color: Colors.blueGrey,
),
),
);
}
}

View file

@ -2,7 +2,7 @@ import 'dart:math';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/collection/thumbnail/error.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/transition_image.dart'; import 'package:aves/widgets/common/transition_image.dart';
@ -71,7 +71,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
_pauseProvider(); _pauseProvider();
} }
bool get isSupported => entry.canDecode;
void _initProvider() { void _initProvider() {
if (!entry.canDecode) return;
_fastThumbnailProvider = ThumbnailProvider( _fastThumbnailProvider = ThumbnailProvider(
ThumbnailProviderKey.fromEntry(entry), ThumbnailProviderKey.fromEntry(entry),
); );
@ -95,6 +99,13 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!entry.canDecode) {
return ErrorThumbnail(
extent: extent,
tooltip: '${entry.mimeType} not supported',
);
}
final fastImage = Image( final fastImage = Image(
key: ValueKey('LQ'), key: ValueKey('LQ'),
image: _fastThumbnailProvider, image: _fastThumbnailProvider,
@ -127,16 +138,9 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
child: frame == null ? fastImage : child, child: frame == null ? fastImage : child,
); );
}, },
errorBuilder: (context, error, stackTrace) => Center( errorBuilder: (context, error, stackTrace) => ErrorThumbnail(
child: Tooltip( extent: extent,
message: error.toString(), tooltip: error.toString(),
preferBelow: false,
child: Icon(
AIcons.error,
size: extent / 2,
color: Colors.blueGrey,
),
),
), ),
width: extent, width: extent,
height: extent, height: extent,

View file

@ -126,7 +126,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
final selectionCount = selection.length; final selectionCount = selection.length;
if (movedCount < selectionCount) { if (movedCount < selectionCount) {
final count = selectionCount - movedCount; final count = selectionCount - movedCount;
showFeedback(context, 'Failed to move ${Intl.plural(count, one: '$count item', other: '$count items')}'); showFeedback(context, 'Failed to ${copy ? 'copy' : 'move'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
} else { } else {
final count = movedCount; final count = movedCount;
showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}'); showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}');

View file

@ -93,7 +93,7 @@ class ImageView extends StatelessWidget {
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(), onTapUp: (tapContext, details, value) => onTap?.call(),
); );
} else { } else if (entry.canDecode) {
final uriImage = UriImage( final uriImage = UriImage(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
@ -111,11 +111,7 @@ class ImageView extends StatelessWidget {
context, context,
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider, imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
), ),
loadFailedChild: EmptyContent( loadFailedChild: _buildError(),
icon: AIcons.error,
text: 'Oops!',
alignment: Alignment.center,
),
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged, scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
@ -123,6 +119,8 @@ class ImageView extends StatelessWidget {
onTapUp: (tapContext, details, value) => onTap?.call(), onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low, filterQuality: FilterQuality.low,
); );
} else {
child = _buildError();
} }
return heroTag != null return heroTag != null
@ -133,4 +131,18 @@ class ImageView extends StatelessWidget {
) )
: child; : child;
} }
Widget _buildError() => GestureDetector(
onTap: () => onTap?.call(),
// use a `Container` with a dummy color to make it expand
// so that we can also detect taps around the title `Text`
child: Container(
color: Colors.transparent,
child: EmptyContent(
icon: AIcons.error,
text: 'Oops!',
alignment: Alignment.center,
),
),
);
} }

View file

@ -6,6 +6,7 @@ import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart'; import 'package:aves/model/mime_types.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
@ -28,7 +29,7 @@ class BasicSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : '?'; final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.unknown;
final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0; final showMegaPixels = entry.isPhoto && entry.megaPixels != null && entry.megaPixels > 0;
final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}'; final resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
@ -36,12 +37,12 @@ class BasicSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InfoRowGroup({ InfoRowGroup({
'Title': entry.bestTitle ?? '?', 'Title': entry.bestTitle ?? Constants.unknown,
'Date': dateText, 'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(), if (entry.isVideo) ..._buildVideoRows(),
if (!entry.isSvg) 'Resolution': resolutionText, if (!entry.isSvg) 'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?', 'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.unknown,
'URI': entry.uri ?? '?', 'URI': entry.uri ?? Constants.unknown,
if (entry.path != null) 'Path': entry.path, if (entry.path != null) 'Path': entry.path,
}), }),
_buildChips(), _buildChips(),

View file

@ -65,6 +65,8 @@ class InfoPageState extends State<InfoPage> {
return ValueListenableBuilder<ImageEntry>( return ValueListenableBuilder<ImageEntry>(
valueListenable: widget.entryNotifier, valueListenable: widget.entryNotifier,
builder: (context, entry, child) { builder: (context, entry, child) {
if (entry == null) return SizedBox.shrink();
final locationAtTop = split && entry.hasGps; final locationAtTop = split && entry.hasGps;
final locationSection = LocationSection( final locationSection = LocationSection(
collection: collection, collection: collection,

View file

@ -228,7 +228,7 @@ class _DateRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : '?'; final dateText = date != null ? '${DateFormat.yMMMd().format(date)}${DateFormat.Hm().format(date)}' : Constants.unknown;
final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}'; final resolution = '${entry.width ?? '?'} × ${entry.height ?? '?'}';
return Row( return Row(
children: [ children: [

View file

@ -24,7 +24,10 @@ import 'package:permission_handler/permission_handler.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
static const routeName = '/'; static const routeName = '/';
const HomePage(); // untyped map as it is coming from the platform
final Map intentData;
const HomePage({this.intentData});
@override @override
_HomePageState createState() => _HomePageState(); _HomePageState createState() => _HomePageState();
@ -64,8 +67,8 @@ class _HomePageState extends State<HomePage> {
unawaited(androidFileUtils.initAppNames()); unawaited(androidFileUtils.initAppNames());
AvesApp.mode = AppMode.main; AvesApp.mode = AppMode.main;
final intentData = await ViewerService.getIntentData(); final intentData = widget.intentData ?? await ViewerService.getIntentData();
if (intentData != null) { if (intentData?.isNotEmpty == true) {
final action = intentData['action']; final action = intentData['action'];
switch (action) { switch (action) {
case 'view': case 'view':

View file

@ -63,7 +63,7 @@ packages:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.2+1" version: "2.3.3"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -200,7 +200,7 @@ packages:
name: firebase name: firebase
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "7.3.0" version: "7.3.2"
firebase_core: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:
@ -261,7 +261,7 @@ packages:
name: flutter_cache_manager name: flutter_cache_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.4.2" version: "2.0.0"
flutter_driver: flutter_driver:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -360,7 +360,7 @@ packages:
name: google_maps_flutter name: google_maps_flutter
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.2" version: "1.0.3"
google_maps_flutter_platform_interface: google_maps_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -570,7 +570,7 @@ packages:
name: path_provider name: path_provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.6.18" version: "1.6.21"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -747,7 +747,7 @@ packages:
name: shared_preferences name: shared_preferences
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.12" version: "0.5.12+2"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
@ -962,7 +962,7 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.7.2" version: "5.7.5"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:

View file

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

File diff suppressed because one or more lines are too long

1
shaders_1.22.2.sksl.json Normal file

File diff suppressed because one or more lines are too long