diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java index ec98ee4a9..9af450b94 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppAdapterHandler.java @@ -2,10 +2,17 @@ package deckers.thibault.aves.channelhandlers; 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.net.Uri; import androidx.annotation.NonNull; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -21,6 +28,20 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler { @Override public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { switch (call.method) { + case "getAppNames": { + result.success(getAppNames()); + break; + } + case "getAppIcon": { + String packageName = call.argument("packageName"); + Integer size = call.argument("size"); + if (packageName == null || size == null) { + result.error("getAppIcon-args", "failed because of missing arguments", null); + return; + } + getAppIcon(packageName, size, result); + break; + } case "edit": { String title = call.argument("title"); Uri uri = Uri.parse(call.argument("uri")); @@ -65,6 +86,28 @@ public class AppAdapterHandler implements MethodChannel.MethodCallHandler { } } + 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); + PackageManager packageManager = context.getPackageManager(); + List resolveInfoList = packageManager.queryIntentActivities(intent, 0); + for (ResolveInfo resolveInfo : resolveInfoList) { + ApplicationInfo applicationInfo = resolveInfo.activityInfo.applicationInfo; + boolean isSystemPackage = (applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + if (!isSystemPackage) { + String appName = String.valueOf(packageManager.getApplicationLabel(applicationInfo)); + nameMap.put(appName, applicationInfo.packageName); + } + } + return nameMap; + } + + private void getAppIcon(String packageName, int size, MethodChannel.Result result) { + new AppIconDecodeTask().execute(new AppIconDecodeTask.Params(context, packageName, size, result)); + } + private void edit(String title, Uri uri, String mimeType) { Intent intent = new Intent(Intent.ACTION_EDIT); intent.setDataAndType(uri, mimeType); diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppIconDecodeTask.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppIconDecodeTask.java new file mode 100644 index 000000000..5ad4e3472 --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/AppIconDecodeTask.java @@ -0,0 +1,113 @@ +package deckers.thibault.aves.channelhandlers; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.AsyncTask; +import android.util.Log; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.Key; +import com.bumptech.glide.request.FutureTarget; +import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.signature.ObjectKey; + +import java.io.ByteArrayOutputStream; + +import deckers.thibault.aves.utils.Utils; +import io.flutter.plugin.common.MethodChannel; + +import static com.bumptech.glide.request.RequestOptions.centerCropTransform; + +public class AppIconDecodeTask extends AsyncTask { + private static final String LOG_TAG = Utils.createLogTag(AppIconDecodeTask.class); + + static class Params { + Context context; + String packageName; + int size; + MethodChannel.Result result; + + Params(Context context, String packageName, int size, MethodChannel.Result result) { + this.context = context; + this.packageName = packageName; + this.size = size; + this.result = result; + } + } + + static class Result { + Params params; + byte[] data; + + Result(Params params, byte[] data) { + this.params = params; + this.data = data; + } + } + + @Override + protected Result doInBackground(Params... params) { + Params p = params[0]; + Context context = p.context; + String packageName = p.packageName; + int size = p.size; + + byte[] data = null; + if (!this.isCancelled()) { + 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(); + + // add signature to ignore cache for images which got modified but kept the same URI + Key signature = new ObjectKey(packageName + size); + RequestOptions options = new RequestOptions() + .signature(signature) + .override(size, size); + + FutureTarget target = Glide.with(context) + .asBitmap() + .apply(options) + .apply(centerCropTransform()) + .load(uri) + .signature(signature) + .submit(size, size); + + try { + Bitmap bmp = target.get(); + if (bmp != null) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + bmp.compress(Bitmap.CompressFormat.PNG, 100, stream); + data = stream.toByteArray(); + } + } catch (InterruptedException e) { + Log.d(LOG_TAG, "getAppIcon with packageName=" + packageName + " interrupted"); + } catch (Exception e) { + e.printStackTrace(); + } + Glide.with(context).clear(target); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + } else { + Log.d(LOG_TAG, "getAppIcon with packageName=" + packageName + " cancelled"); + } + return new Result(p, data); + } + + @Override + protected void onPostExecute(Result result) { + MethodChannel.Result r = result.params.result; + if (result.data != null) { + r.success(result.data); + } else { + r.error("getAppIcon-null", "failed to get icon for packageName=" + result.params.packageName, null); + } + } +} diff --git a/assets/kakaotalk.svg b/assets/kakaotalk.svg deleted file mode 100644 index e721ec2b4..000000000 --- a/assets/kakaotalk.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/telegram.svg b/assets/telegram.svg deleted file mode 100644 index fe7c23504..000000000 --- a/assets/telegram.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index b48eba16e..791198115 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/album/all_collection_drawer.dart'; import 'package:aves/widgets/album/all_collection_page.dart'; import 'package:aves/widgets/common/fake_app_bar.dart'; +import 'package:aves/widgets/common/icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -14,6 +15,7 @@ import 'package:permission_handler/permission_handler.dart'; void main() async { await settings.init(); await androidFileUtils.init(); + await IconUtils.init(); runApp(AvesApp()); } diff --git a/lib/utils/android_app_service.dart b/lib/utils/android_app_service.dart index fadc62c11..f8b313cd4 100644 --- a/lib/utils/android_app_service.dart +++ b/lib/utils/android_app_service.dart @@ -1,9 +1,34 @@ +import 'dart:typed_data'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; class AndroidAppService { static const platform = const MethodChannel('deckers.thibault/aves/app'); + static Future getAppNames() async { + try { + final result = await platform.invokeMethod('getAppNames'); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getAppNames failed with exception=${e.message}'); + } + return Map(); + } + + static Future getAppIcon(String packageName, int size) async { + try { + final result = await platform.invokeMethod('getAppIcon', { + 'packageName': packageName, + 'size': size, + }); + return result as Uint8List; + } on PlatformException catch (e) { + debugPrint('getAppIcon failed with exception=${e.message}'); + } + return Uint8List(0); + } + static edit(String uri, String mimeType) async { try { await platform.invokeMethod('edit', { diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 15c19849a..b93051c17 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -5,16 +5,15 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); typedef void AndroidFileUtilsCallback(String key, dynamic oldValue, dynamic newValue); class AndroidFileUtils { - String dcimPath; - String picturesPath; + String dcimPath, downloadPath, picturesPath; AndroidFileUtils._private(); init() async { - // TODO TLAD find storage root - // getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' + // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' final ext = '/storage/emulated/0'; dcimPath = join(ext, 'DCIM'); + downloadPath = join(ext, 'Download'); picturesPath = join(ext, 'Pictures'); } @@ -22,7 +21,5 @@ class AndroidFileUtils { bool isScreenshotsPath(String path) => path != null && path.startsWith(dcimPath) && path.endsWith('Screenshots'); - bool isKakaoTalkPath(String path) => path != null && path.startsWith(picturesPath) && path.endsWith('KakaoTalk'); - - bool isTelegramPath(String path) => path != null && path.startsWith(picturesPath) && path.endsWith('Telegram'); + bool isDownloadPath(String path) => path == downloadPath; } diff --git a/lib/widgets/common/app_icon.dart b/lib/widgets/common/app_icon.dart new file mode 100644 index 000000000..027139089 --- /dev/null +++ b/lib/widgets/common/app_icon.dart @@ -0,0 +1,49 @@ +import 'dart:typed_data'; + +import 'package:aves/utils/android_app_service.dart'; +import 'package:flutter/material.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class AppIcon extends StatefulWidget { + final String packageName; + final double size; + final double devicePixelRatio; + + const AppIcon({ + Key key, + @required this.packageName, + @required this.size, + @required this.devicePixelRatio, + }) : super(key: key); + + @override + State createState() => AppIconState(); +} + +class AppIconState extends State { + Future _byteLoader; + + @override + void initState() { + super.initState(); + final dim = (widget.size * widget.devicePixelRatio).round(); + _byteLoader = AndroidAppService.getAppIcon(widget.packageName, dim); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _byteLoader, + builder: (futureContext, AsyncSnapshot snapshot) { + final bytes = (snapshot.connectionState == ConnectionState.done && !snapshot.hasError) ? snapshot.data : kTransparentImage; + return bytes.length > 0 + ? Image.memory( + bytes, + width: widget.size, + height: widget.size, + ) + : SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/widgets/common/icons.dart b/lib/widgets/common/icons.dart index 1d54f51e0..403688cd2 100644 --- a/lib/widgets/common/icons.dart +++ b/lib/widgets/common/icons.dart @@ -1,7 +1,9 @@ import 'package:aves/model/image_entry.dart'; +import 'package:aves/utils/android_app_service.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/app_icon.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; +import 'package:path/path.dart'; class VideoIcon extends StatelessWidget { final ImageEntry entry; @@ -84,15 +86,34 @@ class OverlayIcon extends StatelessWidget { } class IconUtils { + static Map appNameMap; + + static init() async { + appNameMap = await AndroidAppService.getAppNames(); + } + + static Widget _buildAlbumIcon(Widget child) => Material( + type: MaterialType.circle, + elevation: 3, + color: Colors.transparent, + shadowColor: Colors.black, + child: child, + ); + static Widget getAlbumIcon(BuildContext context, String albumDirectory) { - if (androidFileUtils.isCameraPath(albumDirectory)) { - return Icon(Icons.photo_camera); - } else if (androidFileUtils.isScreenshotsPath(albumDirectory)) { - return Icon(Icons.smartphone); - } else if (androidFileUtils.isKakaoTalkPath(albumDirectory)) { - return SvgPicture.asset('assets/kakaotalk.svg', width: IconTheme.of(context).size); - } else if (androidFileUtils.isTelegramPath(albumDirectory)) { - return SvgPicture.asset('assets/telegram.svg', width: IconTheme.of(context).size); + if (albumDirectory == null) return null; + if (androidFileUtils.isCameraPath(albumDirectory)) return _buildAlbumIcon(Icon(Icons.photo_camera)); + if (androidFileUtils.isScreenshotsPath(albumDirectory)) return _buildAlbumIcon(Icon(Icons.smartphone)); + if (androidFileUtils.isDownloadPath(albumDirectory)) return _buildAlbumIcon(Icon(Icons.file_download)); + + final parts = albumDirectory.split(separator); + if (albumDirectory.startsWith(androidFileUtils.picturesPath) && appNameMap.keys.contains(parts.last)) { + final packageName = appNameMap[parts.last]; + return _buildAlbumIcon(AppIcon( + packageName: packageName, + size: IconTheme.of(context).size, + devicePixelRatio: MediaQuery.of(context).devicePixelRatio, + )); } return null; }