From e50dd952a8a6fd5b349c5b6a645df1b9f1c070b6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 20 Oct 2020 17:51:21 +0900 Subject: [PATCH] Kotlin migration (WIP) --- .../aves/channel/calls/AppAdapterHandler.java | 283 ------------------ .../channel/calls/AppShortcutHandler.java | 82 ----- .../aves/channel/calls/ImageDecodeTask.java | 2 +- .../aves/channel/calls/ImageFileHandler.java | 2 +- .../channel/calls/MethodResultWrapper.java | 32 -- .../aves/channel/calls/AppAdapterHandler.kt | 255 ++++++++++++++++ .../aves/channel/calls/AppShortcutHandler.kt | 65 ++++ .../aves/channel/calls/MethodResultWrapper.kt | 22 ++ .../aves/metadata/ExifInterfaceHelper.kt | 10 +- lib/services/android_app_service.dart | 26 +- 10 files changed, 364 insertions(+), 415 deletions(-) delete mode 100644 android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java delete mode 100644 android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java delete mode 100644 android/app/src/main/java/deckers/thibault/aves/channel/calls/MethodResultWrapper.java create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MethodResultWrapper.kt diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java deleted file mode 100644 index 0c586276a..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppAdapterHandler.java +++ /dev/null @@ -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> urisByMimeType = call.argument("urisByMimeType"); - shareMultiple(title, urisByMimeType); - result.success(null); - break; - } - default: - result.notImplemented(); - break; - } - } - - private Map getAppNames() { - Map 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 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 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 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> urisByMimeType) { - if (urisByMimeType == null) return; - - ArrayList 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)); - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java deleted file mode 100644 index 9d6e3c64c..000000000 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/AppShortcutHandler.java +++ /dev/null @@ -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 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 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); - } -} diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java index 17c021b8d..f9e151dab 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/ImageDecodeTask.java @@ -70,7 +70,7 @@ public class ImageDecodeTask extends AsyncTask 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()); - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt new file mode 100644 index 000000000..d8dcc7243 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -0,0 +1,255 @@ +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 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" -> Thread { getAppIcon(call, MethodResultWrapper(result)) }.start() + "getAppNames" -> Thread { getAppNames(MethodResultWrapper(result)) }.start() + "getEnv" -> result.success(System.getenv()) + "edit" -> { + val title = call.argument("title") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val mimeType = call.argument("mimeType") + result.success(edit(title, uri, mimeType)) + } + "open" -> { + val title = call.argument("title") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val mimeType = call.argument("mimeType") + result.success(open(title, uri, mimeType)) + } + "openMap" -> { + val geoUri = call.argument("geoUri")?.let { Uri.parse(it) } + result.success(openMap(geoUri)) + } + "setAs" -> { + val title = call.argument("title") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val mimeType = call.argument("mimeType") + result.success(setAs(title, uri, mimeType)) + } + "share" -> { + val title = call.argument("title") + val urisByMimeType = call.argument>>("urisByMimeType")!! + result.success(shareMultiple(title, urisByMimeType)) + } + else -> result.notImplemented() + } + } + + private fun getAppNames(result: MethodChannel.Result) { + val nameMap = HashMap() + 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("packageName") + val 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 + 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>?): 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[0], mimeTypes[0]) + } + + var mimeType = "*/*" + if (mimeTypes.size == 1) { + // items have the same mime type & subtype + mimeType = mimeTypes[0] + } else { + // items have different subtypes + val mimeTypeTypes = mimeTypes.map { it.split("/") }.distinct() + if (mimeTypeTypes.size == 1) { + // items have the same mime type + mimeType = mimeTypeTypes[0].toString() + "/*" + } + } + + 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" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt new file mode 100644 index 000000000..5766f7579 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt @@ -0,0 +1,65 @@ +package deckers.thibault.aves.channel.calls + +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.text.TextUtils +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 + +class AppShortcutHandler(private val context: Context) : MethodCallHandler { + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "canPin" -> result.success(canPin()) + "pin" -> { + Thread { pin(call) }.start() + result.success(null) + } + else -> result.notImplemented() + } + } + + private fun canPin() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) + + private fun pin(call: MethodCall) { + if (!canPin()) return + + val label = call.argument("label") ?: return + val iconBytes = call.argument("iconBytes") + val filters = call.argument>("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" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MethodResultWrapper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MethodResultWrapper.kt new file mode 100644 index 000000000..19eb6854b --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MethodResultWrapper.kt @@ -0,0 +1,22 @@ +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 +class MethodResultWrapper internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result { + private val handler: Handler = Handler(Looper.getMainLooper()) + + override fun success(result: Any?) { + handler.post { methodResult.success(result) } + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + handler.post { methodResult.error(errorCode, errorMessage, errorDetails) } + } + + override fun notImplemented() { + handler.post { methodResult.notImplemented() } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt index df8191ae7..ab030fe52 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt @@ -25,7 +25,7 @@ object ExifInterfaceHelper { ExifInterface.TAG_ORIENTATION, ) - private val baseTags: Map = hashMapOf( + private val baseTags: Map = mapOf( ExifInterface.TAG_APERTURE_VALUE to TagMapper(ExifDirectoryBase.TAG_APERTURE, DirType.EXIF_IFD0, TagFormat.RATIONAL), ExifInterface.TAG_ARTIST to TagMapper(ExifDirectoryBase.TAG_ARTIST, DirType.EXIF_IFD0, TagFormat.ASCII), ExifInterface.TAG_BITS_PER_SAMPLE to TagMapper(ExifDirectoryBase.TAG_BITS_PER_SAMPLE, DirType.EXIF_IFD0, TagFormat.SHORT), @@ -133,12 +133,12 @@ object ExifInterfaceHelper { ExifInterface.TAG_Y_RESOLUTION to TagMapper(ExifDirectoryBase.TAG_Y_RESOLUTION, DirType.EXIF_IFD0, TagFormat.RATIONAL), ) - private val thumbnailTags: Map = hashMapOf( + private val thumbnailTags: Map = mapOf( ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_OFFSET, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0201 ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH to TagMapper(ExifThumbnailDirectory.TAG_THUMBNAIL_LENGTH, DirType.EXIF_THUMBNAIL, TagFormat.LONG), // IFD_TIFF_TAGS or IFD_THUMBNAIL_TAGS 0x0202 ) - private val gpsTags: Map = hashMapOf( + private val gpsTags: Map = mapOf( ExifInterface.TAG_GPS_ALTITUDE to TagMapper(GpsDirectory.TAG_ALTITUDE, DirType.GPS, TagFormat.RATIONAL), ExifInterface.TAG_GPS_ALTITUDE_REF to TagMapper(GpsDirectory.TAG_ALTITUDE_REF, DirType.GPS, TagFormat.BYTE), ExifInterface.TAG_GPS_AREA_INFORMATION to TagMapper(GpsDirectory.TAG_AREA_INFORMATION, DirType.GPS, TagFormat.COMMENT), @@ -173,11 +173,11 @@ object ExifInterfaceHelper { ExifInterface.TAG_GPS_VERSION_ID to TagMapper(GpsDirectory.TAG_VERSION_ID, DirType.GPS, TagFormat.BYTE), ) - private val xmpTags: Map = hashMapOf( + private val xmpTags: Map = mapOf( ExifInterface.TAG_XMP to null, // IFD_TIFF_TAGS 0x02BC ) - private val rawTags: Map = hashMapOf( + private val rawTags: Map = mapOf( // DNG ExifInterface.TAG_DEFAULT_CROP_SIZE to null, // IFD_EXIF_TAGS 0xC620 ExifInterface.TAG_DNG_VERSION to null, // IFD_EXIF_TAGS 0xC612 diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index d2375e305..7549d69af 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -41,9 +41,9 @@ class AndroidAppService { return {}; } - static Future edit(String uri, String mimeType) async { + static Future edit(String uri, String mimeType) async { try { - await platform.invokeMethod('edit', { + return await platform.invokeMethod('edit', { 'title': 'Edit with:', 'uri': uri, 'mimeType': mimeType, @@ -51,11 +51,12 @@ class AndroidAppService { } on PlatformException catch (e) { debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } + return false; } - static Future open(String uri, String mimeType) async { + static Future open(String uri, String mimeType) async { try { - await platform.invokeMethod('open', { + return await platform.invokeMethod('open', { 'title': 'Open with:', 'uri': uri, 'mimeType': mimeType, @@ -63,22 +64,23 @@ class AndroidAppService { } on PlatformException catch (e) { debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } + return false; } - static Future openMap(String geoUri) async { - if (geoUri == null) return; + static Future openMap(String geoUri) async { try { - await platform.invokeMethod('openMap', { + return await platform.invokeMethod('openMap', { 'geoUri': geoUri, }); } on PlatformException catch (e) { debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } + return false; } - static Future setAs(String uri, String mimeType) async { + static Future setAs(String uri, String mimeType) async { try { - await platform.invokeMethod('setAs', { + return await platform.invokeMethod('setAs', { 'title': 'Set as:', 'uri': uri, 'mimeType': mimeType, @@ -86,19 +88,21 @@ class AndroidAppService { } on PlatformException catch (e) { debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } + return false; } - static Future share(Iterable entries) async { + static Future share(Iterable entries) async { // loosen mime type to a generic one, so we can share with badly defined apps // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); try { - await platform.invokeMethod('share', { + return await platform.invokeMethod('share', { 'title': 'Share via:', 'urisByMimeType': urisByMimeType, }); } on PlatformException catch (e) { debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } + return false; } }