Merge branch 'develop'
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '1.22.1'
|
||||
flutter-version: '1.22.2'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
|
6
.github/workflows/release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
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):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,8 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_1.22.1.sksl.json
|
||||
flutter build appbundle --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.2.sksl.json
|
||||
rm $AVES_STORE_FILE
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
|
|
@ -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 localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
|
@ -5,31 +15,20 @@ if (localPropertiesFile.exists()) {
|
|||
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')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
// Keys
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
// for release using credentials stored in a local file
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
keystorePropertiesFile.withReader('UTF-8') { reader ->
|
||||
keystoreProperties.load(reader)
|
||||
}
|
||||
} else {
|
||||
// for release using credentials in environment variables set up by Github Actions
|
||||
// warning: in property file, single quotes should be escaped with a backslash
|
||||
|
@ -42,7 +41,7 @@ if (keystorePropertiesFile.exists()) {
|
|||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 30 // latest (or latest-1 if the sources of latest SDK are unavailable)
|
||||
compileSdkVersion 30
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
@ -54,24 +53,14 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "deckers.thibault.aves"
|
||||
// some Java 8 APIs (java.util.stream, etc.) require minSdkVersion 24
|
||||
// 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
|
||||
// TODO TLAD try minSdkVersion 23 when kotlin migration is done
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 30 // same as compileSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
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 {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
|
@ -105,22 +94,16 @@ repositories {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
// enable support for Java 8 language APIs (stream, optional, etc.)
|
||||
// coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
|
||||
|
||||
implementation 'androidx.core:core:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation "androidx.exifinterface:exifinterface:1.3.0"
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
|
||||
implementation 'androidx.core:core-ktx:1.5.0-alpha04' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.1'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.15.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'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
|
||||
kapt 'androidx.annotation:annotation:1.1.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
||||
|
||||
compileOnly rootProject.findProject(':streams_channel')
|
||||
}
|
||||
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
apply plugin: 'com.google.firebase.crashlytics'
|
||||
|
|
|
@ -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>
|
|
@ -24,7 +24,8 @@
|
|||
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,12 +26,13 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
|
||||
private val intentStreamHandler = IntentStreamHandler()
|
||||
private var intentDataMap: MutableMap<String, Any?>? = null
|
||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
handleIntent(intent)
|
||||
intentDataMap = extractIntentData(intent)
|
||||
|
||||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
||||
|
@ -50,15 +51,13 @@ class MainActivity : FlutterActivity() {
|
|||
when (call.method) {
|
||||
"getIntentData" -> {
|
||||
result.success(intentDataMap)
|
||||
intentDataMap = null
|
||||
intentDataMap.clear()
|
||||
}
|
||||
"pick" -> {
|
||||
result.success(intentDataMap)
|
||||
intentDataMap = null
|
||||
val resultUri = call.argument<String>("uri")
|
||||
if (resultUri != null) {
|
||||
val pickedUri = call.argument<String>("uri")
|
||||
if (pickedUri != null) {
|
||||
val intent = Intent().apply {
|
||||
data = Uri.parse(resultUri)
|
||||
data = Uri.parse(pickedUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
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
|
||||
|
||||
val search = ShortcutInfoCompat.Builder(this, "search")
|
||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||
.setIntent(Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/search"))
|
||||
.build()
|
||||
.setShortLabel(getString(R.string.search_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_search))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/search")
|
||||
)
|
||||
.build()
|
||||
|
||||
val videos = ShortcutInfoCompat.Builder(this, "videos")
|
||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||
.setIntent(Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}")))
|
||||
.build()
|
||||
.setShortLabel(getString(R.string.videos_shortcut_short_label))
|
||||
.setIcon(IconCompat.createWithResource(this, R.mipmap.ic_shortcut_movie))
|
||||
.setIntent(
|
||||
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
|
||||
.putExtra("page", "/collection")
|
||||
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
|
||||
)
|
||||
.build()
|
||||
|
||||
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
Log.i(LOG_TAG, "onNewIntent intent=$intent")
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
intentStreamHandler.notifyNewIntent()
|
||||
intentStreamHandler.notifyNewIntent(extractIntentData(intent))
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
Log.i(LOG_TAG, "handleIntent intent=$intent")
|
||||
if (intent == null) return
|
||||
when (intent.action) {
|
||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_MAIN -> {
|
||||
val page = intent.getStringExtra("page")
|
||||
if (page != null) {
|
||||
intentDataMap = hashMapOf(
|
||||
"page" to page,
|
||||
"filters" to intent.getStringArrayExtra("filters")?.toList(),
|
||||
intent.getStringExtra("page")?.let { page ->
|
||||
return hashMapOf(
|
||||
"page" to page,
|
||||
"filters" to intent.getStringArrayExtra("filters")?.toList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_VIEW -> {
|
||||
val uri = intent.data
|
||||
val mimeType = intent.type
|
||||
if (uri != null && mimeType != null) {
|
||||
intentDataMap = hashMapOf(
|
||||
"action" to "view",
|
||||
"uri" to uri.toString(),
|
||||
"mimeType" to mimeType,
|
||||
intent.data?.let { uri ->
|
||||
return hashMapOf(
|
||||
"action" to "view",
|
||||
"uri" to uri.toString(),
|
||||
"mimeType" to intent.type, // MIME type is optional
|
||||
)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
|
||||
intentDataMap = hashMapOf(
|
||||
"action" to "pick",
|
||||
"mimeType" to intent.type,
|
||||
return hashMapOf(
|
||||
"action" to "pick",
|
||||
"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) {
|
||||
val treeUri = data.data
|
||||
val treeUri = data?.data
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
PermissionManager.onPermissionResult(requestCode, null)
|
||||
return
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -55,6 +55,8 @@ import deckers.thibault.aves.utils.StorageUtils
|
|||
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.IOException
|
||||
import java.util.*
|
||||
|
@ -63,15 +65,15 @@ import kotlin.math.roundToLong
|
|||
class MetadataHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAllMetadata" -> Thread { getAllMetadata(call, MethodResultWrapper(result)) }.start()
|
||||
"getCatalogMetadata" -> Thread { getCatalogMetadata(call, MethodResultWrapper(result)) }.start()
|
||||
"getOverlayMetadata" -> Thread { getOverlayMetadata(call, MethodResultWrapper(result)) }.start()
|
||||
"getContentResolverMetadata" -> Thread { getContentResolverMetadata(call, MethodResultWrapper(result)) }.start()
|
||||
"getExifInterfaceMetadata" -> Thread { getExifInterfaceMetadata(call, MethodResultWrapper(result)) }.start()
|
||||
"getMediaMetadataRetrieverMetadata" -> Thread { getMediaMetadataRetrieverMetadata(call, MethodResultWrapper(result)) }.start()
|
||||
"getEmbeddedPictures" -> Thread { getEmbeddedPictures(call, MethodResultWrapper(result)) }.start()
|
||||
"getExifThumbnails" -> Thread { getExifThumbnails(call, MethodResultWrapper(result)) }.start()
|
||||
"getXmpThumbnails" -> Thread { getXmpThumbnails(call, MethodResultWrapper(result)) }.start()
|
||||
"getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) }
|
||||
"getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) }
|
||||
"getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) }
|
||||
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) }
|
||||
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
|
||||
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
|
||||
"getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -374,7 +376,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
val num = it.numerator
|
||||
val denom = it.denominator
|
||||
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()
|
||||
else -> it.toString()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -4,7 +4,10 @@ import io.flutter.plugin.common.EventChannel
|
|||
import io.flutter.plugin.common.EventChannel.EventSink
|
||||
|
||||
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) {
|
||||
this.eventSink = eventSink
|
||||
|
@ -12,7 +15,7 @@ class IntentStreamHandler : EventChannel.StreamHandler {
|
|||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
fun notifyNewIntent() {
|
||||
eventSink.success(true)
|
||||
fun notifyNewIntent(intentData: MutableMap<String, Any?>?) {
|
||||
eventSink?.success(intentData)
|
||||
}
|
||||
}
|
|
@ -3,31 +3,36 @@ package deckers.thibault.aves.channel.streams
|
|||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
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 {
|
||||
private lateinit var eventSink: EventSink
|
||||
private lateinit var handler: Handler
|
||||
private var knownEntries: Map<Int, Int>? = null
|
||||
|
||||
private var knownEntries: Map<Int, Int?>? = null
|
||||
|
||||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
knownEntries = arguments["knownEntries"] as Map<Int, Int>?
|
||||
knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
|
||||
}
|
||||
}
|
||||
|
||||
override fun onListen(arguments: Any?, eventSink: EventSink) {
|
||||
this.eventSink = eventSink
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
Thread { fetchAll() }.start()
|
||||
|
||||
GlobalScope.launch { fetchAll() }
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {}
|
||||
|
||||
private fun success(result: Map<String, Any>) {
|
||||
private fun success(result: FieldMap) {
|
||||
handler.post { eventSink.success(result) }
|
||||
}
|
||||
|
||||
|
@ -36,7 +41,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
}
|
||||
|
||||
private fun fetchAll() {
|
||||
MediaStoreImageProvider().fetchAll(context, knownEntries) { success(it) }
|
||||
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
|
||||
endOfStream()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -19,185 +19,186 @@ object ExifInterfaceHelper {
|
|||
// ExifInterface always states it has the following attributes
|
||||
// and returns "0" instead of "null" when they are actually missing
|
||||
private val neverNullTags = listOf(
|
||||
ExifInterface.TAG_IMAGE_LENGTH,
|
||||
ExifInterface.TAG_IMAGE_WIDTH,
|
||||
ExifInterface.TAG_LIGHT_SOURCE,
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.TAG_IMAGE_LENGTH,
|
||||
ExifInterface.TAG_IMAGE_WIDTH,
|
||||
ExifInterface.TAG_LIGHT_SOURCE,
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
)
|
||||
|
||||
private val baseTags: Map<String, TagMapper?> = hashMapOf(
|
||||
ExifInterface.TAG_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_ARTIST to TagMapper(ExifDirectoryBase.TAG_ARTIST, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_BODY_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_BODY_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_BRIGHTNESS_VALUE to TagMapper(ExifDirectoryBase.TAG_BRIGHTNESS_VALUE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_CAMERA_OWNER_NAME to TagMapper(ExifDirectoryBase.TAG_CAMERA_OWNER_NAME, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_CFA_PATTERN to TagMapper(ExifDirectoryBase.TAG_CFA_PATTERN, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_COLOR_SPACE to TagMapper(ExifDirectoryBase.TAG_COLOR_SPACE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_COMPONENTS_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_COMPONENTS_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_COMPRESSED_AVERAGE_BITS_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_COMPRESSION to TagMapper(ExifDirectoryBase.TAG_COMPRESSION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_CONTRAST to TagMapper(ExifDirectoryBase.TAG_CONTRAST, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_COPYRIGHT to TagMapper(ExifDirectoryBase.TAG_COPYRIGHT, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_CUSTOM_RENDERED to TagMapper(ExifDirectoryBase.TAG_CUSTOM_RENDERED, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_DATETIME to TagMapper(ExifDirectoryBase.TAG_DATETIME, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_DATETIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_DATETIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_DEVICE_SETTING_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_DIGITAL_ZOOM_RATIO to TagMapper(ExifDirectoryBase.TAG_DIGITAL_ZOOM_RATIO, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_EXIF_VERSION to TagMapper(ExifDirectoryBase.TAG_EXIF_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_EXPOSURE_BIAS_VALUE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_BIAS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_EXPOSURE_MODE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_EXPOSURE_PROGRAM to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_PROGRAM, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_EXPOSURE_TIME to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_TIME, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FILE_SOURCE to TagMapper(ExifDirectoryBase.TAG_FILE_SOURCE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_FLASH to TagMapper(ExifDirectoryBase.TAG_FLASH, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_FLASHPIX_VERSION to TagMapper(ExifDirectoryBase.TAG_FLASHPIX_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_FLASH_ENERGY to TagMapper(ExifDirectoryBase.TAG_FLASH_ENERGY, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FOCAL_LENGTH to TagMapper(ExifDirectoryBase.TAG_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM to TagMapper(ExifDirectoryBase.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_F_NUMBER to TagMapper(ExifDirectoryBase.TAG_FNUMBER, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GAIN_CONTROL to TagMapper(ExifDirectoryBase.TAG_GAIN_CONTROL, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_GAMMA to TagMapper(ExifDirectoryBase.TAG_GAMMA, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_IMAGE_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_IMAGE_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_IMAGE_UNIQUE_ID to TagMapper(ExifDirectoryBase.TAG_IMAGE_UNIQUE_ID, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_INTEROPERABILITY_INDEX to TagMapper(ExifDirectoryBase.TAG_INTEROP_INDEX, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_ISO_SPEED to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_YYY, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_ZZZ, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_LENS_MAKE to TagMapper(ExifDirectoryBase.TAG_LENS_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_LENS_MODEL to TagMapper(ExifDirectoryBase.TAG_LENS_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_LENS_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_LENS_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_LENS_SPECIFICATION to TagMapper(ExifDirectoryBase.TAG_LENS_SPECIFICATION, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_LIGHT_SOURCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_MAKE to TagMapper(ExifDirectoryBase.TAG_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_MAKER_NOTE to TagMapper(ExifDirectoryBase.TAG_MAKERNOTE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_MAX_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_MAX_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_METERING_MODE to TagMapper(ExifDirectoryBase.TAG_METERING_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_MODEL to TagMapper(ExifDirectoryBase.TAG_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_NEW_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_NEW_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_OECF to TagMapper(ExifDirectoryBase.TAG_OPTO_ELECTRIC_CONVERSION_FUNCTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_OFFSET_TIME to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_OFFSET_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_OFFSET_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_ORIENTATION to TagMapper(ExifDirectoryBase.TAG_ORIENTATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_ISO_EQUIVALENT, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION to TagMapper(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PIXEL_X_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_PIXEL_Y_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_PLANAR_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_PLANAR_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PRIMARY_CHROMATICITIES to TagMapper(ExifDirectoryBase.TAG_PRIMARY_CHROMATICITIES, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_RECOMMENDED_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_REFERENCE_BLACK_WHITE to TagMapper(ExifDirectoryBase.TAG_REFERENCE_BLACK_WHITE, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_RELATED_SOUND_FILE to TagMapper(ExifDirectoryBase.TAG_RELATED_SOUND_FILE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_ROWS_PER_STRIP to TagMapper(ExifDirectoryBase.TAG_ROWS_PER_STRIP, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_SAMPLES_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_SAMPLES_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SATURATION to TagMapper(ExifDirectoryBase.TAG_SATURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SCENE_CAPTURE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_CAPTURE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SCENE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_TYPE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_SENSING_METHOD to TagMapper(ExifDirectoryBase.TAG_SENSING_METHOD, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SENSITIVITY_TYPE to TagMapper(ExifDirectoryBase.TAG_SENSITIVITY_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SHARPNESS to TagMapper(ExifDirectoryBase.TAG_SHARPNESS, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SHUTTER_SPEED_VALUE to TagMapper(ExifDirectoryBase.TAG_SHUTTER_SPEED, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_SOFTWARE to TagMapper(ExifDirectoryBase.TAG_SOFTWARE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE to TagMapper(ExifDirectoryBase.TAG_SPATIAL_FREQ_RESPONSE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_SPECTRAL_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_SPECTRAL_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_STANDARD_OUTPUT_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_STRIP_BYTE_COUNTS to TagMapper(ExifDirectoryBase.TAG_STRIP_BYTE_COUNTS, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_STRIP_OFFSETS to TagMapper(ExifDirectoryBase.TAG_STRIP_OFFSETS, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBJECT_AREA to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION_TIFF_EP, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBJECT_DISTANCE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE_RANGE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBJECT_LOCATION to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBSEC_TIME to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0101
|
||||
ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0100
|
||||
ExifInterface.TAG_TRANSFER_FUNCTION to TagMapper(ExifDirectoryBase.TAG_TRANSFER_FUNCTION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_USER_COMMENT to TagMapper(ExifDirectoryBase.TAG_USER_COMMENT, DirType.EXIF_IFD0, TagFormat.COMMENT),
|
||||
ExifInterface.TAG_WHITE_BALANCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_WHITE_POINT to TagMapper(ExifDirectoryBase.TAG_WHITE_POINT, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_Y_CB_CR_COEFFICIENTS to TagMapper(ExifDirectoryBase.TAG_YCBCR_COEFFICIENTS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_Y_CB_CR_POSITIONING to TagMapper(ExifDirectoryBase.TAG_YCBCR_POSITIONING, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING to TagMapper(ExifDirectoryBase.TAG_YCBCR_SUBSAMPLING, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
private fun isNeverNull(tag: String): Boolean = neverNullTags.contains(tag)
|
||||
|
||||
private val baseTags: Map<String, TagMapper?> = mapOf(
|
||||
ExifInterface.TAG_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_ARTIST to TagMapper(ExifDirectoryBase.TAG_ARTIST, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_BODY_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_BODY_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_BRIGHTNESS_VALUE to TagMapper(ExifDirectoryBase.TAG_BRIGHTNESS_VALUE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_CAMERA_OWNER_NAME to TagMapper(ExifDirectoryBase.TAG_CAMERA_OWNER_NAME, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_CFA_PATTERN to TagMapper(ExifDirectoryBase.TAG_CFA_PATTERN, DirType.EXIF_IFD0, TagFormat.BYTE), // spec format: UNDEFINED, e.g. [Red,Green][Green,Blue]
|
||||
ExifInterface.TAG_COLOR_SPACE to TagMapper(ExifDirectoryBase.TAG_COLOR_SPACE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_COMPONENTS_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_COMPONENTS_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.BYTE), // spec format: UNDEFINED, e.g. [Y,Cb,Cr]
|
||||
ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_COMPRESSED_AVERAGE_BITS_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_COMPRESSION to TagMapper(ExifDirectoryBase.TAG_COMPRESSION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_CONTRAST to TagMapper(ExifDirectoryBase.TAG_CONTRAST, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_COPYRIGHT to TagMapper(ExifDirectoryBase.TAG_COPYRIGHT, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_CUSTOM_RENDERED to TagMapper(ExifDirectoryBase.TAG_CUSTOM_RENDERED, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_DATETIME to TagMapper(ExifDirectoryBase.TAG_DATETIME, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_DATETIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_DATETIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_DEVICE_SETTING_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_DIGITAL_ZOOM_RATIO to TagMapper(ExifDirectoryBase.TAG_DIGITAL_ZOOM_RATIO, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_EXIF_VERSION to TagMapper(ExifDirectoryBase.TAG_EXIF_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_EXPOSURE_BIAS_VALUE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_BIAS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_EXPOSURE_MODE to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_EXPOSURE_PROGRAM to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_PROGRAM, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_EXPOSURE_TIME to TagMapper(ExifDirectoryBase.TAG_EXPOSURE_TIME, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FILE_SOURCE to TagMapper(ExifDirectoryBase.TAG_FILE_SOURCE, DirType.EXIF_IFD0, TagFormat.SHORT), // spec format: UNDEFINED
|
||||
ExifInterface.TAG_FLASH to TagMapper(ExifDirectoryBase.TAG_FLASH, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_FLASHPIX_VERSION to TagMapper(ExifDirectoryBase.TAG_FLASHPIX_VERSION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_FLASH_ENERGY to TagMapper(ExifDirectoryBase.TAG_FLASH_ENERGY, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FOCAL_LENGTH to TagMapper(ExifDirectoryBase.TAG_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM to TagMapper(ExifDirectoryBase.TAG_35MM_FILM_EQUIV_FOCAL_LENGTH, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_FOCAL_PLANE_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_F_NUMBER to TagMapper(ExifDirectoryBase.TAG_FNUMBER, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GAIN_CONTROL to TagMapper(ExifDirectoryBase.TAG_GAIN_CONTROL, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_GAMMA to TagMapper(ExifDirectoryBase.TAG_GAMMA, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_IMAGE_DESCRIPTION to TagMapper(ExifDirectoryBase.TAG_IMAGE_DESCRIPTION, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_IMAGE_UNIQUE_ID to TagMapper(ExifDirectoryBase.TAG_IMAGE_UNIQUE_ID, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_INTEROPERABILITY_INDEX to TagMapper(ExifDirectoryBase.TAG_INTEROP_INDEX, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_ISO_SPEED to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_YYY, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ to TagMapper(ExifDirectoryBase.TAG_ISO_SPEED_LATITUDE_ZZZ, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_LENS_MAKE to TagMapper(ExifDirectoryBase.TAG_LENS_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_LENS_MODEL to TagMapper(ExifDirectoryBase.TAG_LENS_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_LENS_SERIAL_NUMBER to TagMapper(ExifDirectoryBase.TAG_LENS_SERIAL_NUMBER, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_LENS_SPECIFICATION to TagMapper(ExifDirectoryBase.TAG_LENS_SPECIFICATION, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_LIGHT_SOURCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_MAKE to TagMapper(ExifDirectoryBase.TAG_MAKE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_MAKER_NOTE to TagMapper(ExifDirectoryBase.TAG_MAKERNOTE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_MAX_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_MAX_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_METERING_MODE to TagMapper(ExifDirectoryBase.TAG_METERING_MODE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_MODEL to TagMapper(ExifDirectoryBase.TAG_MODEL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_NEW_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_NEW_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_OECF to TagMapper(ExifDirectoryBase.TAG_OPTO_ELECTRIC_CONVERSION_FUNCTION, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_OFFSET_TIME to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_OFFSET_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_OFFSET_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_TIME_ZONE_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_ORIENTATION to TagMapper(ExifDirectoryBase.TAG_ORIENTATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_ISO_EQUIVALENT, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION to TagMapper(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PIXEL_X_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_PIXEL_Y_DIMENSION to TagMapper(ExifDirectoryBase.TAG_EXIF_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_PLANAR_CONFIGURATION to TagMapper(ExifDirectoryBase.TAG_PLANAR_CONFIGURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_PRIMARY_CHROMATICITIES to TagMapper(ExifDirectoryBase.TAG_PRIMARY_CHROMATICITIES, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX to TagMapper(ExifDirectoryBase.TAG_RECOMMENDED_EXPOSURE_INDEX, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_REFERENCE_BLACK_WHITE to TagMapper(ExifDirectoryBase.TAG_REFERENCE_BLACK_WHITE, DirType.EXIF_IFD0, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_RELATED_SOUND_FILE to TagMapper(ExifDirectoryBase.TAG_RELATED_SOUND_FILE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_RESOLUTION_UNIT to TagMapper(ExifDirectoryBase.TAG_RESOLUTION_UNIT, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_ROWS_PER_STRIP to TagMapper(ExifDirectoryBase.TAG_ROWS_PER_STRIP, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_SAMPLES_PER_PIXEL to TagMapper(ExifDirectoryBase.TAG_SAMPLES_PER_PIXEL, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SATURATION to TagMapper(ExifDirectoryBase.TAG_SATURATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SCENE_CAPTURE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_CAPTURE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SCENE_TYPE to TagMapper(ExifDirectoryBase.TAG_SCENE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT), // spec format: UNDEFINED
|
||||
ExifInterface.TAG_SENSING_METHOD to TagMapper(ExifDirectoryBase.TAG_SENSING_METHOD, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SENSITIVITY_TYPE to TagMapper(ExifDirectoryBase.TAG_SENSITIVITY_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SHARPNESS to TagMapper(ExifDirectoryBase.TAG_SHARPNESS, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SHUTTER_SPEED_VALUE to TagMapper(ExifDirectoryBase.TAG_SHUTTER_SPEED, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_SOFTWARE to TagMapper(ExifDirectoryBase.TAG_SOFTWARE, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE to TagMapper(ExifDirectoryBase.TAG_SPATIAL_FREQ_RESPONSE, DirType.EXIF_IFD0, TagFormat.UNDEFINED),
|
||||
ExifInterface.TAG_SPECTRAL_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_SPECTRAL_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY to TagMapper(ExifDirectoryBase.TAG_STANDARD_OUTPUT_SENSITIVITY, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_STRIP_BYTE_COUNTS to TagMapper(ExifDirectoryBase.TAG_STRIP_BYTE_COUNTS, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_STRIP_OFFSETS to TagMapper(ExifDirectoryBase.TAG_STRIP_OFFSETS, DirType.EXIF_IFD0, TagFormat.LONG),
|
||||
ExifInterface.TAG_SUBFILE_TYPE to TagMapper(ExifDirectoryBase.TAG_SUBFILE_TYPE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBJECT_AREA to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION_TIFF_EP, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBJECT_DISTANCE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_SUBJECT_DISTANCE_RANGE to TagMapper(ExifDirectoryBase.TAG_SUBJECT_DISTANCE_RANGE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBJECT_LOCATION to TagMapper(ExifDirectoryBase.TAG_SUBJECT_LOCATION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_SUBSEC_TIME to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_SUBSEC_TIME_DIGITIZED to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_DIGITIZED, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_SUBSEC_TIME_ORIGINAL to TagMapper(ExifDirectoryBase.TAG_SUBSECOND_TIME_ORIGINAL, DirType.EXIF_IFD0, TagFormat.ASCII),
|
||||
ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_HEIGHT, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0101
|
||||
ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH to TagMapper(ExifDirectoryBase.TAG_IMAGE_WIDTH, DirType.EXIF_IFD0, TagFormat.LONG), // IFD_THUMBNAIL_TAGS 0x0100
|
||||
ExifInterface.TAG_TRANSFER_FUNCTION to TagMapper(ExifDirectoryBase.TAG_TRANSFER_FUNCTION, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_USER_COMMENT to TagMapper(ExifDirectoryBase.TAG_USER_COMMENT, DirType.EXIF_IFD0, TagFormat.COMMENT),
|
||||
ExifInterface.TAG_WHITE_BALANCE to TagMapper(ExifDirectoryBase.TAG_WHITE_BALANCE, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_WHITE_POINT to TagMapper(ExifDirectoryBase.TAG_WHITE_POINT, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_X_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_X_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_Y_CB_CR_COEFFICIENTS to TagMapper(ExifDirectoryBase.TAG_YCBCR_COEFFICIENTS, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_Y_CB_CR_POSITIONING to TagMapper(ExifDirectoryBase.TAG_YCBCR_POSITIONING, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING to TagMapper(ExifDirectoryBase.TAG_YCBCR_SUBSAMPLING, DirType.EXIF_IFD0, TagFormat.SHORT),
|
||||
ExifInterface.TAG_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL),
|
||||
)
|
||||
|
||||
private val thumbnailTags: Map<String, TagMapper?> = hashMapOf(
|
||||
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0201
|
||||
ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0202
|
||||
private val 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_LENGTH to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0202
|
||||
)
|
||||
|
||||
private val gpsTags: Map<String, TagMapper?> = hashMapOf(
|
||||
ExifInterface.TAG_GPS_ALTITUDE to TagMapper(GpsDirectory.TAG_ALTITUDE, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_ALTITUDE_REF to TagMapper(GpsDirectory.TAG_ALTITUDE_REF, DirType.GPS, TagFormat.BYTE),
|
||||
ExifInterface.TAG_GPS_AREA_INFORMATION to TagMapper(GpsDirectory.TAG_AREA_INFORMATION, DirType.GPS, TagFormat.COMMENT),
|
||||
ExifInterface.TAG_GPS_DATESTAMP to TagMapper(GpsDirectory.TAG_DATE_STAMP, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_BEARING to TagMapper(GpsDirectory.TAG_DEST_BEARING, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_DEST_BEARING_REF to TagMapper(GpsDirectory.TAG_DEST_BEARING_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE to TagMapper(GpsDirectory.TAG_DEST_DISTANCE, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE_REF to TagMapper(GpsDirectory.TAG_DEST_DISTANCE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE to TagMapper(GpsDirectory.TAG_DEST_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DIFFERENTIAL to TagMapper(GpsDirectory.TAG_DIFFERENTIAL, DirType.GPS, TagFormat.SHORT),
|
||||
ExifInterface.TAG_GPS_DOP to TagMapper(GpsDirectory.TAG_DOP, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_H_POSITIONING_ERROR to TagMapper(GpsDirectory.TAG_H_POSITIONING_ERROR, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION to TagMapper(GpsDirectory.TAG_IMG_DIRECTION, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION_REF to TagMapper(GpsDirectory.TAG_IMG_DIRECTION_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_LATITUDE to TagMapper(GpsDirectory.TAG_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_LATITUDE_REF to TagMapper(GpsDirectory.TAG_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_LONGITUDE to TagMapper(GpsDirectory.TAG_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_MAP_DATUM to TagMapper(GpsDirectory.TAG_MAP_DATUM, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_MEASURE_MODE to TagMapper(GpsDirectory.TAG_MEASURE_MODE, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_PROCESSING_METHOD to TagMapper(GpsDirectory.TAG_PROCESSING_METHOD, DirType.GPS, TagFormat.COMMENT),
|
||||
ExifInterface.TAG_GPS_SATELLITES to TagMapper(GpsDirectory.TAG_SATELLITES, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_SPEED to TagMapper(GpsDirectory.TAG_SPEED, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_SPEED_REF to TagMapper(GpsDirectory.TAG_SPEED_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_STATUS to TagMapper(GpsDirectory.TAG_STATUS, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_TIMESTAMP to TagMapper(GpsDirectory.TAG_TIME_STAMP, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_TRACK to TagMapper(GpsDirectory.TAG_TRACK, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_TRACK_REF to TagMapper(GpsDirectory.TAG_TRACK_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_VERSION_ID to TagMapper(GpsDirectory.TAG_VERSION_ID, DirType.GPS, TagFormat.BYTE),
|
||||
private val gpsTags: Map<String, TagMapper?> = mapOf(
|
||||
ExifInterface.TAG_GPS_ALTITUDE to TagMapper(GpsDirectory.TAG_ALTITUDE, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_ALTITUDE_REF to TagMapper(GpsDirectory.TAG_ALTITUDE_REF, DirType.GPS, TagFormat.BYTE),
|
||||
ExifInterface.TAG_GPS_AREA_INFORMATION to TagMapper(GpsDirectory.TAG_AREA_INFORMATION, DirType.GPS, TagFormat.COMMENT),
|
||||
ExifInterface.TAG_GPS_DATESTAMP to TagMapper(GpsDirectory.TAG_DATE_STAMP, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_BEARING to TagMapper(GpsDirectory.TAG_DEST_BEARING, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_DEST_BEARING_REF to TagMapper(GpsDirectory.TAG_DEST_BEARING_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE to TagMapper(GpsDirectory.TAG_DEST_DISTANCE, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE_REF to TagMapper(GpsDirectory.TAG_DEST_DISTANCE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE to TagMapper(GpsDirectory.TAG_DEST_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_DEST_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_DIFFERENTIAL to TagMapper(GpsDirectory.TAG_DIFFERENTIAL, DirType.GPS, TagFormat.SHORT),
|
||||
ExifInterface.TAG_GPS_DOP to TagMapper(GpsDirectory.TAG_DOP, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_H_POSITIONING_ERROR to TagMapper(GpsDirectory.TAG_H_POSITIONING_ERROR, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION to TagMapper(GpsDirectory.TAG_IMG_DIRECTION, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION_REF to TagMapper(GpsDirectory.TAG_IMG_DIRECTION_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_LATITUDE to TagMapper(GpsDirectory.TAG_LATITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_LATITUDE_REF to TagMapper(GpsDirectory.TAG_LATITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_LONGITUDE to TagMapper(GpsDirectory.TAG_LONGITUDE, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_LONGITUDE_REF to TagMapper(GpsDirectory.TAG_LONGITUDE_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_MAP_DATUM to TagMapper(GpsDirectory.TAG_MAP_DATUM, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_MEASURE_MODE to TagMapper(GpsDirectory.TAG_MEASURE_MODE, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_PROCESSING_METHOD to TagMapper(GpsDirectory.TAG_PROCESSING_METHOD, DirType.GPS, TagFormat.COMMENT),
|
||||
ExifInterface.TAG_GPS_SATELLITES to TagMapper(GpsDirectory.TAG_SATELLITES, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_SPEED to TagMapper(GpsDirectory.TAG_SPEED, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_SPEED_REF to TagMapper(GpsDirectory.TAG_SPEED_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_STATUS to TagMapper(GpsDirectory.TAG_STATUS, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_TIMESTAMP to TagMapper(GpsDirectory.TAG_TIME_STAMP, DirType.GPS, TagFormat.RATIONAL_ARRAY),
|
||||
ExifInterface.TAG_GPS_TRACK to TagMapper(GpsDirectory.TAG_TRACK, DirType.GPS, TagFormat.RATIONAL),
|
||||
ExifInterface.TAG_GPS_TRACK_REF to TagMapper(GpsDirectory.TAG_TRACK_REF, DirType.GPS, TagFormat.ASCII),
|
||||
ExifInterface.TAG_GPS_VERSION_ID to TagMapper(GpsDirectory.TAG_VERSION_ID, DirType.GPS, TagFormat.BYTE),
|
||||
)
|
||||
|
||||
private val xmpTags: Map<String, TagMapper?> = hashMapOf(
|
||||
ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC
|
||||
private val xmpTags: Map<String, TagMapper?> = mapOf(
|
||||
ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC
|
||||
)
|
||||
|
||||
private val rawTags: Map<String, TagMapper?> = hashMapOf(
|
||||
// DNG
|
||||
ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620
|
||||
ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612
|
||||
// ORF
|
||||
ExifInterface.TAG_ORF_ASPECT_FRAME to TagMapper(OlympusImageProcessingMakernoteDirectory.TagAspectFrame, DirType.OIPM, TagFormat.LONG), // ORF_IMAGE_PROCESSING_TAGS 0x1113
|
||||
ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageLength, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0102
|
||||
ExifInterface.TAG_ORF_PREVIEW_IMAGE_START to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageStart, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0101
|
||||
ExifInterface.TAG_ORF_THUMBNAIL_IMAGE to TagMapper(OlympusMakernoteDirectory.TAG_THUMBNAIL_IMAGE, DirType.OM, TagFormat.UNDEFINED), // ORF_MAKER_NOTE_TAGS 0x0100
|
||||
// RW2
|
||||
ExifInterface.TAG_RW2_ISO to TagMapper(PanasonicRawIFD0Directory.TagIso, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0017
|
||||
ExifInterface.TAG_RW2_JPG_FROM_RAW to TagMapper(PanasonicRawIFD0Directory.TagJpgFromRaw, DirType.PRIFD0, TagFormat.UNDEFINED), // IFD_TIFF_TAGS 0x002E
|
||||
ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorBottomBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0006
|
||||
ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorLeftBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0005
|
||||
ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorRightBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0007
|
||||
ExifInterface.TAG_RW2_SENSOR_TOP_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorTopBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0004
|
||||
private val rawTags: Map<String, TagMapper?> = mapOf(
|
||||
// DNG
|
||||
ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620
|
||||
ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612
|
||||
// ORF
|
||||
ExifInterface.TAG_ORF_ASPECT_FRAME to TagMapper(OlympusImageProcessingMakernoteDirectory.TagAspectFrame, DirType.OIPM, TagFormat.LONG), // ORF_IMAGE_PROCESSING_TAGS 0x1113
|
||||
ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageLength, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0102
|
||||
ExifInterface.TAG_ORF_PREVIEW_IMAGE_START to TagMapper(OlympusCameraSettingsMakernoteDirectory.TagPreviewImageStart, DirType.OCSM, TagFormat.LONG), // ORF_CAMERA_SETTINGS_TAGS 0x0101
|
||||
ExifInterface.TAG_ORF_THUMBNAIL_IMAGE to TagMapper(OlympusMakernoteDirectory.TAG_THUMBNAIL_IMAGE, DirType.OM, TagFormat.UNDEFINED), // ORF_MAKER_NOTE_TAGS 0x0100
|
||||
// RW2
|
||||
ExifInterface.TAG_RW2_ISO to TagMapper(PanasonicRawIFD0Directory.TagIso, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0017
|
||||
ExifInterface.TAG_RW2_JPG_FROM_RAW to TagMapper(PanasonicRawIFD0Directory.TagJpgFromRaw, DirType.PRIFD0, TagFormat.UNDEFINED), // IFD_TIFF_TAGS 0x002E
|
||||
ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorBottomBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0006
|
||||
ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorLeftBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0005
|
||||
ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorRightBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0007
|
||||
ExifInterface.TAG_RW2_SENSOR_TOP_BORDER to TagMapper(PanasonicRawIFD0Directory.TagSensorTopBorder, DirType.PRIFD0, TagFormat.LONG), // IFD_TIFF_TAGS 0x0004
|
||||
)
|
||||
|
||||
// list of known ExifInterface tags (as of androidx.exifinterface:exifinterface:1.3.0)
|
||||
// mapped to metadata-extractor tags (as of v2.14.0)
|
||||
@JvmField
|
||||
val allTags: Map<String, TagMapper?> = hashMapOf<String, TagMapper?>(
|
||||
).apply {
|
||||
putAll(baseTags)
|
||||
|
@ -207,7 +208,6 @@ object ExifInterfaceHelper {
|
|||
putAll(rawTags)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun describeAll(exif: ExifInterface): Map<String, Map<String, String>> {
|
||||
// initialize metadata-extractor directories that we will fill
|
||||
// by tags converted from the ExifInterface attributes
|
||||
|
@ -231,7 +231,7 @@ object ExifInterfaceHelper {
|
|||
for ((exifInterfaceTag, mapper) in tags) {
|
||||
if (exif.hasAttribute(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) {
|
||||
val dir = metadataExtractorDirs[mapper.dirType] ?: error("Directory type ${mapper.dirType} does not have a matching Directory instance")
|
||||
val type = mapper.type
|
||||
|
@ -260,7 +260,7 @@ object ExifInterfaceHelper {
|
|||
if (value != null && (value != "0" || !neverNullTags.contains(exifInterfaceTag))) {
|
||||
val obj: Any? = when (mapper.format) {
|
||||
TagFormat.ASCII, TagFormat.COMMENT, TagFormat.UNDEFINED -> value
|
||||
TagFormat.BYTE -> value.toByteArray()
|
||||
TagFormat.BYTE -> exif.getAttributeBytes(exifInterfaceTag)
|
||||
TagFormat.SHORT -> value.toShortOrNull()
|
||||
TagFormat.LONG -> value.toLongOrNull()
|
||||
TagFormat.RATIONAL -> toRational(value)
|
||||
|
|
|
@ -9,40 +9,38 @@ import java.text.SimpleDateFormat
|
|||
import java.util.*
|
||||
|
||||
object MediaMetadataRetrieverHelper {
|
||||
@JvmField
|
||||
val allKeys = hashMapOf(
|
||||
MediaMetadataRetriever.METADATA_KEY_ALBUM to "Album",
|
||||
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "Album Artist",
|
||||
MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist",
|
||||
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
|
||||
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
|
||||
MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
|
||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "Disc Number",
|
||||
MediaMetadataRetriever.METADATA_KEY_DURATION to "Duration",
|
||||
MediaMetadataRetriever.METADATA_KEY_EXIF_LENGTH to "Exif Length",
|
||||
MediaMetadataRetriever.METADATA_KEY_EXIF_OFFSET to "Exif Offset",
|
||||
MediaMetadataRetriever.METADATA_KEY_GENRE to "Genre",
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "Has Audio",
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "Has Video",
|
||||
MediaMetadataRetriever.METADATA_KEY_LOCATION to "Location",
|
||||
MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIME Type",
|
||||
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
|
||||
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
|
||||
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
|
||||
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
|
||||
MediaMetadataRetriever.METADATA_KEY_ALBUM to "Album",
|
||||
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST to "Album Artist",
|
||||
MediaMetadataRetriever.METADATA_KEY_ARTIST to "Artist",
|
||||
MediaMetadataRetriever.METADATA_KEY_AUTHOR to "Author",
|
||||
MediaMetadataRetriever.METADATA_KEY_BITRATE to "Bitrate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE to "Capture Framerate",
|
||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER to "CD Track Number",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE to "Color Range",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD to "Color Standard",
|
||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER to "Color Transfer",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION to "Compilation",
|
||||
MediaMetadataRetriever.METADATA_KEY_COMPOSER to "Composer",
|
||||
MediaMetadataRetriever.METADATA_KEY_DATE to "Date",
|
||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER to "Disc Number",
|
||||
MediaMetadataRetriever.METADATA_KEY_DURATION to "Duration",
|
||||
MediaMetadataRetriever.METADATA_KEY_GENRE to "Genre",
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO to "Has Audio",
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO to "Has Video",
|
||||
MediaMetadataRetriever.METADATA_KEY_LOCATION to "Location",
|
||||
MediaMetadataRetriever.METADATA_KEY_MIMETYPE to "MIME Type",
|
||||
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
|
||||
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
|
||||
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
|
||||
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
putAll(hashMapOf(
|
||||
putAll(
|
||||
hashMapOf(
|
||||
MediaMetadataRetriever.METADATA_KEY_HAS_IMAGE to "Has Image",
|
||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_COUNT to "Image Count",
|
||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT to "Image Height",
|
||||
|
@ -50,7 +48,16 @@ object MediaMetadataRetrieverHelper {
|
|||
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION to "Image Rotation",
|
||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH to "Image Width",
|
||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT to "Video Frame Count",
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
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_BITRATE -> {
|
||||
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 -> {
|
||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||
|
|
|
@ -19,7 +19,6 @@ object Metadata {
|
|||
const val DIR_MEDIA = "Media"
|
||||
|
||||
// interpret EXIF code to angle (0, 90, 180 or 270 degrees)
|
||||
@JvmStatic
|
||||
fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90
|
||||
ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_FLIP_VERTICAL -> 180
|
||||
|
@ -28,13 +27,11 @@ object Metadata {
|
|||
}
|
||||
|
||||
// interpret EXIF code to whether the image is flipped
|
||||
@JvmStatic
|
||||
fun isFlippedForExifCode(exifOrientation: Int): Boolean = when (exifOrientation) {
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL, ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_FLIP_VERTICAL, ExifInterface.ORIENTATION_TRANSPOSE -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getExifCode(rotationDegrees: Int, isFlipped: Boolean): Int {
|
||||
return when (rotationDegrees) {
|
||||
90 -> if (isFlipped) ExifInterface.ORIENTATION_TRANSVERSE else ExifInterface.ORIENTATION_ROTATE_90
|
||||
|
@ -45,7 +42,6 @@ object Metadata {
|
|||
}
|
||||
|
||||
// yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)?
|
||||
@JvmStatic
|
||||
fun parseVideoMetadataDate(metadataDate: String?): Long {
|
||||
var dateString = metadataDate ?: return 0
|
||||
|
||||
|
@ -61,7 +57,7 @@ object Metadata {
|
|||
var timeZone: TimeZone? = null
|
||||
val timeZoneMatcher = Pattern.compile("(Z|[+-]\\d{4})$").matcher(dateString)
|
||||
if (timeZoneMatcher.find()) {
|
||||
timeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replace("Z".toRegex(), ""))
|
||||
timeZone = TimeZone.getTimeZone("GMT${timeZoneMatcher.group().replace("Z".toRegex(), "")}")
|
||||
dateString = timeZoneMatcher.replaceAll("")
|
||||
}
|
||||
|
||||
|
|
|
@ -1,23 +1,13 @@
|
|||
package deckers.thibault.aves.model
|
||||
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.model.provider.FieldMap
|
||||
|
||||
class AvesImageEntry(map: Map<String?, Any?>) {
|
||||
@JvmField
|
||||
class AvesImageEntry(map: FieldMap) {
|
||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
||||
|
||||
@JvmField
|
||||
val path = map["path"] as String? // best effort to get local path
|
||||
|
||||
@JvmField
|
||||
val mimeType = map["mimeType"] as String
|
||||
|
||||
@JvmField
|
||||
val width = map["width"] as Int
|
||||
|
||||
@JvmField
|
||||
val height = map["height"] as Int
|
||||
|
||||
@JvmField
|
||||
val rotationDegrees = map["rotationDegrees"] as Int
|
||||
}
|
|
@ -24,6 +24,7 @@ import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
|
|||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
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.StorageUtils
|
||||
import java.io.IOException
|
||||
|
@ -48,7 +49,7 @@ class SourceImageEntry {
|
|||
this.sourceMimeType = sourceMimeType
|
||||
}
|
||||
|
||||
constructor(map: Map<String, Any?>) {
|
||||
constructor(map: FieldMap) {
|
||||
uri = Uri.parse(map["uri"] as String)
|
||||
path = map["path"] as String?
|
||||
sourceMimeType = map["sourceMimeType"] as String
|
||||
|
@ -69,21 +70,21 @@ class SourceImageEntry {
|
|||
this.dateModifiedSecs = dateModifiedSecs
|
||||
}
|
||||
|
||||
fun toMap(): Map<String, Any?> {
|
||||
fun toMap(): FieldMap {
|
||||
return hashMapOf(
|
||||
"uri" to uri.toString(),
|
||||
"path" to path,
|
||||
"sourceMimeType" to sourceMimeType,
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
|
||||
"sizeBytes" to sizeBytes,
|
||||
"title" to title,
|
||||
"dateModifiedSecs" to dateModifiedSecs,
|
||||
"sourceDateTakenMillis" to sourceDateTakenMillis,
|
||||
"durationMillis" to durationMillis,
|
||||
// only for map export
|
||||
"contentId" to contentId,
|
||||
"uri" to uri.toString(),
|
||||
"path" to path,
|
||||
"sourceMimeType" to sourceMimeType,
|
||||
"width" to width,
|
||||
"height" to height,
|
||||
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
|
||||
"sizeBytes" to sizeBytes,
|
||||
"title" to title,
|
||||
"dateModifiedSecs" to dateModifiedSecs,
|
||||
"sourceDateTakenMillis" to sourceDateTakenMillis,
|
||||
"durationMillis" to durationMillis,
|
||||
// only for map export
|
||||
"contentId" to contentId,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -7,7 +7,6 @@ import com.bumptech.glide.load.resource.bitmap.TransformationUtils
|
|||
import deckers.thibault.aves.metadata.Metadata.getExifCode
|
||||
|
||||
object BitmapUtils {
|
||||
@JvmStatic
|
||||
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
|
||||
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
|
||||
if (rotationDegrees == 0 && !isFlipped) return bitmap
|
||||
|
@ -15,12 +14,10 @@ object BitmapUtils {
|
|||
return TransformationUtils.rotateImageExif(getBitmapPool(context), bitmap, exifOrientation)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun centerSquareCrop(context: Context, bitmap: Bitmap?, size: Int): Bitmap? {
|
||||
bitmap ?: return bitmap
|
||||
return TransformationUtils.centerCrop(getBitmapPool(context), bitmap, size, size)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getBitmapPool(context: Context) = Glide.get(context).bitmapPool
|
||||
}
|
|
@ -7,7 +7,6 @@ object LogUtils {
|
|||
private val LOG_TAG_PACKAGE_PATTERN = Pattern.compile("(\\w)(\\w*)\\.")
|
||||
|
||||
// create an Android logger friendly log tag for the specified class
|
||||
@JvmStatic
|
||||
fun createTag(clazz: Class<*>): String {
|
||||
// shorten class name to "a.b.CccDdd"
|
||||
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(clazz.name).replaceAll("$1.")
|
||||
|
|
|
@ -26,13 +26,10 @@ object MimeTypes {
|
|||
private const val MP2T = "video/mp2t"
|
||||
private const val WEBM = "video/webm"
|
||||
|
||||
@JvmStatic
|
||||
fun isImage(mimeType: String?) = mimeType != null && mimeType.startsWith(IMAGE)
|
||||
|
||||
@JvmStatic
|
||||
fun isVideo(mimeType: String?) = mimeType != null && mimeType.startsWith(VIDEO)
|
||||
|
||||
@JvmStatic
|
||||
// returns whether the specified MIME type represents
|
||||
// a raster image format that allows an alpha channel
|
||||
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
|
||||
|
@ -41,7 +38,6 @@ object MimeTypes {
|
|||
}
|
||||
|
||||
// as of Flutter v1.22.0
|
||||
@JvmStatic
|
||||
fun isSupportedByFlutter(mimeType: String, rotationDegrees: Int?, isFlipped: Boolean?) = when (mimeType) {
|
||||
JPEG, GIF, WEBP, BMP, WBMP, ICO, SVG -> true
|
||||
PNG -> rotationDegrees ?: 0 == 0 && !(isFlipped ?: false)
|
||||
|
@ -49,7 +45,6 @@ object MimeTypes {
|
|||
}
|
||||
|
||||
// as of metadata-extractor v2.14.0
|
||||
@JvmStatic
|
||||
fun isSupportedByMetadataExtractor(mimeType: String) = when (mimeType) {
|
||||
WBMP, MP2T, WEBM -> false
|
||||
else -> true
|
||||
|
@ -59,7 +54,6 @@ object MimeTypes {
|
|||
// but we need to rotate the decoded bitmap for the other formats
|
||||
// maybe related to ExifInterface version used by Glide:
|
||||
// https://github.com/bumptech/glide/blob/master/gradle.properties#L21
|
||||
@JvmStatic
|
||||
fun needRotationAfterGlide(mimeType: String) = when (mimeType) {
|
||||
DNG, HEIC, HEIF, PNG, WEBP -> true
|
||||
else -> false
|
||||
|
@ -68,7 +62,6 @@ object MimeTypes {
|
|||
// Thumbnails obtained from the Media Store are automatically rotated
|
||||
// according to EXIF orientation when decoding images of known formats
|
||||
// but we need to rotate the decoded bitmap for the other formats
|
||||
@JvmStatic
|
||||
fun needRotationAfterContentResolverThumbnail(mimeType: String) = when (mimeType) {
|
||||
DNG, PNG -> true
|
||||
else -> false
|
||||
|
|
|
@ -22,7 +22,6 @@ object PermissionManager {
|
|||
// permission request code to pending runnable
|
||||
private val pendingPermissionMap = ConcurrentHashMap<Int, PendingPermissionHandler>()
|
||||
|
||||
@JvmStatic
|
||||
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")
|
||||
pendingPermissionMap[VOLUME_ACCESS_REQUEST_CODE] = PendingPermissionHandler(path, onGranted, onDenied)
|
||||
|
@ -47,12 +46,10 @@ object PermissionManager {
|
|||
(if (treeUri != null) handler.onGranted else handler.onDenied)()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getGrantedDirForPath(context: Context, anyPath: String): String? {
|
||||
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
|
||||
val accessibleDirs = getAccessibleDirs(context)
|
||||
|
||||
|
@ -103,16 +100,15 @@ object PermissionManager {
|
|||
return inaccessibleDirs
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun revokeDirectoryAccess(context: Context, path: String) {
|
||||
StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
||||
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.releasePersistableUriPermission(it, flags)
|
||||
}
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
|
||||
// returns paths matching URIs granted by the user
|
||||
@JvmStatic
|
||||
fun getGrantedDirs(context: Context): Set<String> {
|
||||
val grantedDirs = HashSet<String>()
|
||||
for (uriPermission in context.contentResolver.persistedUriPermissions) {
|
||||
|
@ -127,7 +123,7 @@ object PermissionManager {
|
|||
val accessibleDirs = HashSet(getGrantedDirs(context))
|
||||
// from Android R, we no longer have access permission by default on primary volume
|
||||
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")
|
||||
return accessibleDirs
|
||||
|
|
|
@ -6,7 +6,6 @@ import android.content.Context
|
|||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
|
@ -36,15 +35,13 @@ object StorageUtils {
|
|||
// primary volume path, with trailing "/"
|
||||
private var mPrimaryVolumePath: String? = null
|
||||
|
||||
val primaryVolumePath: String
|
||||
get() {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath()
|
||||
}
|
||||
return mPrimaryVolumePath!!
|
||||
fun getPrimaryVolumePath(context: Context): String {
|
||||
if (mPrimaryVolumePath == null) {
|
||||
mPrimaryVolumePath = findPrimaryVolumePath(context)
|
||||
}
|
||||
return mPrimaryVolumePath!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getVolumePaths(context: Context): Array<String> {
|
||||
if (mStorageVolumePaths == null) {
|
||||
mStorageVolumePaths = findVolumePaths(context)
|
||||
|
@ -52,7 +49,6 @@ object StorageUtils {
|
|||
return mStorageVolumePaths!!
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getVolumePath(context: Context, anyPath: String): String? {
|
||||
return getVolumePaths(context).firstOrNull { anyPath.startsWith(it) }
|
||||
}
|
||||
|
@ -76,8 +72,17 @@ object StorageUtils {
|
|||
return pathSteps.iterator()
|
||||
}
|
||||
|
||||
private fun findPrimaryVolumePath(): String {
|
||||
return ensureTrailingSeparator(Environment.getExternalStorageDirectory().absolutePath)
|
||||
private fun findPrimaryVolumePath(context: Context): String? {
|
||||
// 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")
|
||||
|
@ -126,10 +131,10 @@ object StorageUtils {
|
|||
}
|
||||
} else {
|
||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||
val path = Environment.getExternalStorageDirectory().absolutePath
|
||||
val rawUserId = path.split(File.separator).lastOrNull()?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
||||
// /storage/emulated/0[1,2,...]
|
||||
if (TextUtils.isEmpty(rawUserId)) {
|
||||
// /storage/emulated/[0,1,2,...]/
|
||||
val path = getPrimaryVolumePath(context)
|
||||
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
||||
if (rawUserId.isEmpty()) {
|
||||
paths.add(rawEmulatedStorageTarget)
|
||||
} else {
|
||||
paths.add(rawEmulatedStorageTarget + File.separator + rawUserId)
|
||||
|
@ -145,30 +150,29 @@ object StorageUtils {
|
|||
}
|
||||
|
||||
// return physicalPaths based on phone model
|
||||
private val physicalPaths: Array<String>
|
||||
@SuppressLint("SdCardPath")
|
||||
get() = arrayOf(
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
)
|
||||
@SuppressLint("SdCardPath")
|
||||
private val physicalPaths = arrayOf(
|
||||
"/storage/sdcard0",
|
||||
"/storage/sdcard1", //Motorola Xoom
|
||||
"/storage/extsdcard", //Samsung SGS3
|
||||
"/storage/sdcard0/external_sdcard", //User request
|
||||
"/mnt/extsdcard",
|
||||
"/mnt/sdcard/external_sd", //Samsung galaxy family
|
||||
"/mnt/external_sd",
|
||||
"/mnt/media_rw/sdcard1", //4.4.2 on CyanogenMod S3
|
||||
"/removable/microsd", //Asus transformer prime
|
||||
"/mnt/emmc",
|
||||
"/storage/external_SD", //LG
|
||||
"/storage/ext_sd", //HTC One Max
|
||||
"/storage/removable/sdcard1", //Sony Xperia Z1
|
||||
"/data/sdext",
|
||||
"/data/sdext2",
|
||||
"/data/sdext3",
|
||||
"/data/sdext4",
|
||||
"/sdcard1", //Sony Xperia Z
|
||||
"/sdcard2", //HTC One M8s
|
||||
"/storage/microsd" //ASUS ZenFone 2
|
||||
)
|
||||
|
||||
/**
|
||||
* Volume tree URIs
|
||||
|
@ -194,7 +198,7 @@ object StorageUtils {
|
|||
|
||||
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? {
|
||||
if (uuid == "primary") {
|
||||
return primaryVolumePath
|
||||
return getPrimaryVolumePath(context)
|
||||
}
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
if (sm != null) {
|
||||
|
@ -255,30 +259,33 @@ object StorageUtils {
|
|||
* Document files
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
fun getDocumentFile(context: Context, anyPath: String, mediaUri: Uri): DocumentFileCompat? {
|
||||
if (requireAccessPermission(anyPath)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// cleanest API to get it
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
if (docUri != null) {
|
||||
return DocumentFileCompat.fromSingleUri(context, docUri)
|
||||
try {
|
||||
if (requireAccessPermission(context, anyPath)) {
|
||||
// need a document URI (not a media content URI) to open a `DocumentFile` output stream
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isMediaStoreContentUri(mediaUri)) {
|
||||
// cleanest API to get it
|
||||
val docUri = MediaStore.getDocumentUri(context, mediaUri)
|
||||
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
|
||||
return getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) }
|
||||
// good old `File`
|
||||
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 DocumentFileCompat.fromFile(File(anyPath))
|
||||
return null
|
||||
}
|
||||
|
||||
// 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
|
||||
@JvmStatic
|
||||
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
|
||||
val cleanDirPath = ensureTrailingSeparator(dirPath)
|
||||
return if (requireAccessPermission(cleanDirPath)) {
|
||||
return if (requireAccessPermission(context, cleanDirPath)) {
|
||||
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
|
||||
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: 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? {
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(File(path)).toString())
|
||||
try {
|
||||
|
@ -351,13 +357,12 @@ object StorageUtils {
|
|||
* Misc
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
fun requireAccessPermission(anyPath: String): Boolean {
|
||||
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
|
||||
// on Android R, we should always require access permission, even on primary volume
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
return true
|
||||
}
|
||||
val onPrimaryVolume = anyPath.startsWith(primaryVolumePath)
|
||||
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
|
||||
return !onPrimaryVolume
|
||||
}
|
||||
|
||||
|
@ -380,10 +385,12 @@ object StorageUtils {
|
|||
} catch (e: FileNotFoundException) {
|
||||
Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri")
|
||||
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? {
|
||||
var effectiveUri = uri
|
||||
// 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
|
||||
|
||||
private fun ensureTrailingSeparator(dirPath: String): String {
|
||||
fun ensureTrailingSeparator(dirPath: String): String {
|
||||
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
|
||||
}
|
||||
|
||||
|
@ -411,7 +418,7 @@ object StorageUtils {
|
|||
class PathSegments(context: Context, fullPath: String) {
|
||||
var volumePath: String? = null // `volumePath` 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 {
|
||||
volumePath = getVolumePath(context, fullPath)
|
||||
|
|
|
@ -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>
|
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 8.4 KiB |
|
@ -1,12 +1,13 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.4.10'
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
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 'com.google.gms:google-services:4.3.4'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.3.0'
|
||||
|
|
|
@ -1,4 +1,18 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.enableR8=true
|
||||
# Project-wide Gradle settings.
|
||||
# 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
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
android.enableR8=true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
#Tue Apr 21 13:20:37 KST 2020
|
||||
#Thu Oct 22 10:54:33 KST 2020
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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
|
||||
|
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.8 MiB |
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 320 KiB |
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 328 KiB |
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.2 MiB |
|
@ -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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appSetup = _setup();
|
||||
_newIntentChannel.receiveBroadcastStream().listen((_) => _onNewIntent());
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map));
|
||||
}
|
||||
|
||||
Future<void> _setup() async {
|
||||
|
@ -109,11 +109,16 @@ class _AvesAppState extends State<AvesApp> {
|
|||
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');
|
||||
_navigatorKey.currentState.pushReplacement(DirectMaterialPageRoute(
|
||||
settings: RouteSettings(name: HomePage.routeName),
|
||||
builder: (_) => firstPage,
|
||||
builder: (_) => getFirstPage(intentData: intentData),
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -125,7 +130,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
future: _appSetup,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) {
|
||||
return firstPage;
|
||||
return getFirstPage();
|
||||
}
|
||||
return Scaffold(
|
||||
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
|
||||
|
|
|
@ -56,6 +56,8 @@ class ImageEntry {
|
|||
this.dateModifiedSecs = dateModifiedSecs;
|
||||
}
|
||||
|
||||
bool get canDecode => !MimeTypes.undecodable.contains(mimeType);
|
||||
|
||||
ImageEntry copyWith({
|
||||
@required String uri,
|
||||
@required String path,
|
||||
|
|
|
@ -9,6 +9,9 @@ class MimeTypes {
|
|||
static const String svg = 'image/svg+xml';
|
||||
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 cr2 = 'image/x-canon-cr2';
|
||||
static const String crw = 'image/x-canon-crw';
|
||||
|
@ -38,4 +41,5 @@ class MimeTypes {
|
|||
|
||||
// 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> undecodable = [crw, psd, tiff]; // TODO TLAD make it dynamic if it depends on OS/lib versions
|
||||
}
|
||||
|
|
|
@ -136,13 +136,15 @@ class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
|
|||
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails));
|
||||
} else {
|
||||
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
|
||||
final sourceUri = movedOp.uri;
|
||||
final newFields = movedOp.newFields;
|
||||
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
|
||||
if (entry != null) {
|
||||
fromAlbums.add(entry.directory);
|
||||
movedEntries.add(entry);
|
||||
await moveEntry(entry, newFields);
|
||||
if (newFields.isNotEmpty) {
|
||||
final sourceUri = movedOp.uri;
|
||||
final entry = selection.firstWhere((entry) => entry.uri == sourceUri, orElse: () => null);
|
||||
if (entry != null) {
|
||||
fromAlbums.add(entry.directory);
|
||||
movedEntries.add(entry);
|
||||
await moveEntry(entry, newFields);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -41,9 +41,9 @@ class AndroidAppService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<void> edit(String uri, String mimeType) async {
|
||||
static Future<bool> edit(String uri, String mimeType) async {
|
||||
try {
|
||||
await platform.invokeMethod('edit', <String, dynamic>{
|
||||
return await platform.invokeMethod('edit', <String, dynamic>{
|
||||
'title': 'Edit with:',
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
|
@ -51,11 +51,12 @@ class AndroidAppService {
|
|||
} on PlatformException catch (e) {
|
||||
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 {
|
||||
await platform.invokeMethod('open', <String, dynamic>{
|
||||
return await platform.invokeMethod('open', <String, dynamic>{
|
||||
'title': 'Open with:',
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
|
@ -63,22 +64,23 @@ class AndroidAppService {
|
|||
} on PlatformException catch (e) {
|
||||
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> openMap(String geoUri) async {
|
||||
if (geoUri == null) return;
|
||||
static Future<bool> openMap(String geoUri) async {
|
||||
try {
|
||||
await platform.invokeMethod('openMap', <String, dynamic>{
|
||||
return await platform.invokeMethod('openMap', <String, dynamic>{
|
||||
'geoUri': geoUri,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
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 {
|
||||
await platform.invokeMethod('setAs', <String, dynamic>{
|
||||
return await platform.invokeMethod('setAs', <String, dynamic>{
|
||||
'title': 'Set as:',
|
||||
'uri': uri,
|
||||
'mimeType': mimeType,
|
||||
|
@ -86,19 +88,21 @@ class AndroidAppService {
|
|||
} on PlatformException catch (e) {
|
||||
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
|
||||
// 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()));
|
||||
try {
|
||||
await platform.invokeMethod('share', <String, dynamic>{
|
||||
return await platform.invokeMethod('share', <String, dynamic>{
|
||||
'title': 'Share via:',
|
||||
'urisByMimeType': urisByMimeType,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ class Constants {
|
|||
offset: Offset(0.5, 1.0),
|
||||
);
|
||||
|
||||
static const String unknown = 'unknown';
|
||||
|
||||
static const pointNemo = Tuple2(-48.876667, -123.393333);
|
||||
|
||||
static const int infoGroupMaxValueLength = 140;
|
||||
|
|
24
lib/widgets/collection/thumbnail/error.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ import 'dart:math';
|
|||
|
||||
import 'package:aves/model/image_entry.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/uri_image_provider.dart';
|
||||
import 'package:aves/widgets/common/transition_image.dart';
|
||||
|
@ -71,7 +71,11 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
_pauseProvider();
|
||||
}
|
||||
|
||||
bool get isSupported => entry.canDecode;
|
||||
|
||||
void _initProvider() {
|
||||
if (!entry.canDecode) return;
|
||||
|
||||
_fastThumbnailProvider = ThumbnailProvider(
|
||||
ThumbnailProviderKey.fromEntry(entry),
|
||||
);
|
||||
|
@ -95,6 +99,13 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!entry.canDecode) {
|
||||
return ErrorThumbnail(
|
||||
extent: extent,
|
||||
tooltip: '${entry.mimeType} not supported',
|
||||
);
|
||||
}
|
||||
|
||||
final fastImage = Image(
|
||||
key: ValueKey('LQ'),
|
||||
image: _fastThumbnailProvider,
|
||||
|
@ -127,16 +138,9 @@ class _ThumbnailRasterImageState extends State<ThumbnailRasterImage> {
|
|||
child: frame == null ? fastImage : child,
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) => Center(
|
||||
child: Tooltip(
|
||||
message: error.toString(),
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
AIcons.error,
|
||||
size: extent / 2,
|
||||
color: Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
errorBuilder: (context, error, stackTrace) => ErrorThumbnail(
|
||||
extent: extent,
|
||||
tooltip: error.toString(),
|
||||
),
|
||||
width: extent,
|
||||
height: extent,
|
||||
|
|
|
@ -126,7 +126,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
|
|||
final selectionCount = selection.length;
|
||||
if (movedCount < selectionCount) {
|
||||
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 {
|
||||
final count = movedCount;
|
||||
showFeedback(context, '${copy ? 'Copied' : 'Moved'} ${Intl.plural(count, one: '$count item', other: '$count items')}');
|
||||
|
|
|
@ -93,7 +93,7 @@ class ImageView extends StatelessWidget {
|
|||
initialScale: PhotoViewComputedScale.contained,
|
||||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
);
|
||||
} else {
|
||||
} else if (entry.canDecode) {
|
||||
final uriImage = UriImage(
|
||||
uri: entry.uri,
|
||||
mimeType: entry.mimeType,
|
||||
|
@ -111,11 +111,7 @@ class ImageView extends StatelessWidget {
|
|||
context,
|
||||
imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider,
|
||||
),
|
||||
loadFailedChild: EmptyContent(
|
||||
icon: AIcons.error,
|
||||
text: 'Oops!',
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
loadFailedChild: _buildError(),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
scaleStateChangedCallback: onScaleChanged,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
|
@ -123,6 +119,8 @@ class ImageView extends StatelessWidget {
|
|||
onTapUp: (tapContext, details, value) => onTap?.call(),
|
||||
filterQuality: FilterQuality.low,
|
||||
);
|
||||
} else {
|
||||
child = _buildError();
|
||||
}
|
||||
|
||||
return heroTag != null
|
||||
|
@ -133,4 +131,18 @@ class ImageView extends StatelessWidget {
|
|||
)
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/model/filters/tag.dart';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/mime_types.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/widgets/common/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
|
@ -28,7 +29,7 @@ class BasicSection extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 resolutionText = '${entry.width ?? '?'} × ${entry.height ?? '?'}${showMegaPixels ? ' (${entry.megaPixels} MP)' : ''}';
|
||||
|
||||
|
@ -36,12 +37,12 @@ class BasicSection extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoRowGroup({
|
||||
'Title': entry.bestTitle ?? '?',
|
||||
'Title': entry.bestTitle ?? Constants.unknown,
|
||||
'Date': dateText,
|
||||
if (entry.isVideo) ..._buildVideoRows(),
|
||||
if (!entry.isSvg) 'Resolution': resolutionText,
|
||||
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?',
|
||||
'URI': entry.uri ?? '?',
|
||||
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : Constants.unknown,
|
||||
'URI': entry.uri ?? Constants.unknown,
|
||||
if (entry.path != null) 'Path': entry.path,
|
||||
}),
|
||||
_buildChips(),
|
||||
|
|
|
@ -65,6 +65,8 @@ class InfoPageState extends State<InfoPage> {
|
|||
return ValueListenableBuilder<ImageEntry>(
|
||||
valueListenable: widget.entryNotifier,
|
||||
builder: (context, entry, child) {
|
||||
if (entry == null) return SizedBox.shrink();
|
||||
|
||||
final locationAtTop = split && entry.hasGps;
|
||||
final locationSection = LocationSection(
|
||||
collection: collection,
|
||||
|
|
|
@ -228,7 +228,7 @@ class _DateRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 ?? '?'}';
|
||||
return Row(
|
||||
children: [
|
||||
|
|
|
@ -24,7 +24,10 @@ import 'package:permission_handler/permission_handler.dart';
|
|||
class HomePage extends StatefulWidget {
|
||||
static const routeName = '/';
|
||||
|
||||
const HomePage();
|
||||
// untyped map as it is coming from the platform
|
||||
final Map intentData;
|
||||
|
||||
const HomePage({this.intentData});
|
||||
|
||||
@override
|
||||
_HomePageState createState() => _HomePageState();
|
||||
|
@ -64,8 +67,8 @@ class _HomePageState extends State<HomePage> {
|
|||
unawaited(androidFileUtils.initAppNames());
|
||||
|
||||
AvesApp.mode = AppMode.main;
|
||||
final intentData = await ViewerService.getIntentData();
|
||||
if (intentData != null) {
|
||||
final intentData = widget.intentData ?? await ViewerService.getIntentData();
|
||||
if (intentData?.isNotEmpty == true) {
|
||||
final action = intentData['action'];
|
||||
switch (action) {
|
||||
case 'view':
|
||||
|
|
14
pubspec.lock
|
@ -63,7 +63,7 @@ packages:
|
|||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.2+1"
|
||||
version: "2.3.3"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -200,7 +200,7 @@ packages:
|
|||
name: firebase
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
version: "7.3.2"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -261,7 +261,7 @@ packages:
|
|||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
version: "2.0.0"
|
||||
flutter_driver:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
@ -360,7 +360,7 @@ packages:
|
|||
name: google_maps_flutter
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.3"
|
||||
google_maps_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -570,7 +570,7 @@ packages:
|
|||
name: path_provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.18"
|
||||
version: "1.6.21"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -747,7 +747,7 @@ packages:
|
|||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.12"
|
||||
version: "0.5.12+2"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -962,7 +962,7 @@ packages:
|
|||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.7.2"
|
||||
version: "5.7.5"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.2.2+28
|
||||
version: 1.2.3+29
|
||||
|
||||
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||
# - does not support content URIs (by default, but trivial by fork)
|
||||
|
|