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