diff --git a/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java new file mode 100644 index 000000000..7976027df --- /dev/null +++ b/android/app/src/main/java/deckers/thibault/aves/channelhandlers/FileAdapterHandler.java @@ -0,0 +1,208 @@ +package deckers.thibault.aves.channelhandlers; + +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.graphics.Bitmap; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.core.content.FileProvider; + +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 java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +import static com.bumptech.glide.request.RequestOptions.centerCropTransform; + +public class AppAdapterHandler implements MethodChannel.MethodCallHandler { + 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 "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"); + Uri uri = Uri.parse(call.argument("uri")); + String mimeType = call.argument("mimeType"); + share(title, uri, mimeType); + 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); + 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(MethodCall call, MethodChannel.Result result) { + 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; + } + + 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(); + + // 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 (Exception e) { + e.printStackTrace(); + } + Glide.with(context).clear(target); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + return; + } + if (data != null) { + result.success(data); + } else { + result.error("getAppIcon-null", "failed to get icon for packageName=" + packageName, null); + } + } + + 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 share(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)); + } +} diff --git a/lib/model/collection_source.dart b/lib/model/collection_source.dart index 78483e9ad..f5fa47e55 100644 --- a/lib/model/collection_source.dart +++ b/lib/model/collection_source.dart @@ -12,8 +12,8 @@ class CollectionSource { final EventBus _eventBus = EventBus(); List sortedAlbums = List.unmodifiable(const Iterable.empty()); - List sortedCities = List.unmodifiable(const Iterable.empty()); List sortedCountries = List.unmodifiable(const Iterable.empty()); + List sortedPlaces = List.unmodifiable(const Iterable.empty()); List sortedTags = List.unmodifiable(const Iterable.empty()); List get entries => List.unmodifiable(_rawEntries); @@ -125,7 +125,7 @@ class CollectionSource { final locations = _rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails); final lister = (String Function(AddressDetails a) f) => List.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase)); sortedCountries = lister((address) => '${address.countryName};${address.countryCode}'); - sortedCities = lister((address) => address.city); + sortedPlaces = lister((address) => address.place); } void addAll(Iterable entries) { diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index b6ce96e67..4bb063469 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -17,7 +17,7 @@ class LocationFilter extends CollectionFilter { } @override - bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.city && entry.addressDetails.city == _location)); + bool filter(ImageEntry entry) => entry.isLocated && ((level == LocationLevel.country && entry.addressDetails.countryName == _location) || (level == LocationLevel.place && entry.addressDetails.place == _location)); @override String get label => _location; @@ -50,4 +50,4 @@ class LocationFilter extends CollectionFilter { } } -enum LocationLevel { city, country } +enum LocationLevel { place, country } diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index c314123a7..db9489ba7 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -110,7 +110,7 @@ class AddressDetails { final int contentId; final String addressLine, countryCode, countryName, adminArea, locality; - String get city => locality != null && locality.isNotEmpty ? locality : adminArea; + String get place => locality != null && locality.isNotEmpty ? locality : adminArea; AddressDetails({ this.contentId, diff --git a/lib/utils/android_file_service.dart b/lib/utils/android_file_service.dart new file mode 100644 index 000000000..e69de29bb diff --git a/lib/widgets/album/collection_drawer.dart b/lib/widgets/album/collection_drawer.dart index f9e7ea1e8..241c1d73d 100644 --- a/lib/widgets/album/collection_drawer.dart +++ b/lib/widgets/album/collection_drawer.dart @@ -31,7 +31,7 @@ class CollectionDrawer extends StatefulWidget { } class _CollectionDrawerState extends State { - bool _albumsExpanded = false, _citiesExpanded = false, _countriesExpanded = false, _tagsExpanded = false; + bool _albumsExpanded = false, _placesExpanded = false, _countriesExpanded = false, _tagsExpanded = false; CollectionSource get source => widget.source; @@ -153,8 +153,8 @@ class _CollectionDrawerState extends State { break; } } - final cities = source.sortedCities; final countries = source.sortedCountries; + final places = source.sortedPlaces; final tags = source.sortedTags; final drawerItems = [ @@ -193,28 +193,6 @@ class _CollectionDrawerState extends State { ], ), ), - if (cities.isNotEmpty) - SafeArea( - top: false, - bottom: false, - child: ExpansionTile( - leading: const Icon(AIcons.location), - title: Row( - children: [ - const Text('Cities'), - const Spacer(), - Text( - '${cities.length}', - style: TextStyle( - color: (_citiesExpanded ? Theme.of(context).accentColor : Colors.white).withOpacity(.6), - ), - ), - ], - ), - onExpansionChanged: (expanded) => setState(() => _citiesExpanded = expanded), - children: cities.map((s) => buildLocationEntry(LocationLevel.city, s)).toList(), - ), - ), if (countries.isNotEmpty) SafeArea( top: false, @@ -237,6 +215,28 @@ class _CollectionDrawerState extends State { children: countries.map((s) => buildLocationEntry(LocationLevel.country, s)).toList(), ), ), + if (places.isNotEmpty) + SafeArea( + top: false, + bottom: false, + child: ExpansionTile( + leading: const Icon(AIcons.location), + title: Row( + children: [ + const Text('Places'), + const Spacer(), + Text( + '${places.length}', + style: TextStyle( + color: (_placesExpanded ? Theme.of(context).accentColor : Colors.white).withOpacity(.6), + ), + ), + ], + ), + onExpansionChanged: (expanded) => setState(() => _placesExpanded = expanded), + children: places.map((s) => buildLocationEntry(LocationLevel.place, s)).toList(), + ), + ), if (tags.isNotEmpty) SafeArea( top: false, diff --git a/lib/widgets/album/search/search_delegate.dart b/lib/widgets/album/search/search_delegate.dart index 4aa37980b..0a02e4880 100644 --- a/lib/widgets/album/search/search_delegate.dart +++ b/lib/widgets/album/search/search_delegate.dart @@ -71,13 +71,13 @@ class ImageSearchDelegate extends SearchDelegate { ), _buildFilterRow( context: context, - title: 'Cities', - filters: source.sortedCities.where(containQuery).map((s) => LocationFilter(LocationLevel.city, s)), + title: 'Countries', + filters: source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)), ), _buildFilterRow( context: context, - title: 'Countries', - filters: source.sortedCountries.where(containQuery).map((s) => LocationFilter(LocationLevel.country, s)), + title: 'Places', + filters: source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)), ), _buildFilterRow( context: context, diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 3c750e2c0..bbd6b8eac 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -82,8 +82,8 @@ class _LocationSectionState extends State { location = address.addressLine; final country = address.countryName; if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country;${address.countryCode}')); - final city = address.city; - if (city != null && city.isNotEmpty) filters.add(LocationFilter(LocationLevel.city, city)); + final place = address.place; + if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); } else if (entry.hasGps) { location = toDMS(entry.latLng).join(', '); } diff --git a/lib/widgets/stats.dart b/lib/widgets/stats.dart index 5cef20aa4..3dd4c7e8d 100644 --- a/lib/widgets/stats.dart +++ b/lib/widgets/stats.dart @@ -20,7 +20,7 @@ import 'package:percent_indicator/linear_percent_indicator.dart'; class StatsPage extends StatelessWidget { final CollectionLens collection; - final Map entryCountPerCity = {}, entryCountPerCountry = {}, entryCountPerTag = {}; + final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; List get entries => collection.sortedEntries; @@ -30,15 +30,15 @@ class StatsPage extends StatelessWidget { entries.forEach((entry) { if (entry.isLocated) { final address = entry.addressDetails; - final city = address.city; - if (city != null && city.isNotEmpty) { - entryCountPerCity[city] = (entryCountPerCity[city] ?? 0) + 1; - } var country = address.countryName; if (country != null && country.isNotEmpty) { country += ';${address.countryCode}'; entryCountPerCountry[country] = (entryCountPerCountry[country] ?? 0) + 1; } + final place = address.place; + if (place != null && place.isNotEmpty) { + entryCountPerPlace[place] = (entryCountPerPlace[place] ?? 0) + 1; + } } entry.xmpSubjects.forEach((tag) { entryCountPerTag[tag] = (entryCountPerTag[tag] ?? 0) + 1; @@ -87,8 +87,8 @@ class StatsPage extends StatelessWidget { ], ), ), - ..._buildTopFilters(context, 'Top cities', entryCountPerCity, (s) => LocationFilter(LocationLevel.city, s)), ..._buildTopFilters(context, 'Top countries', entryCountPerCountry, (s) => LocationFilter(LocationLevel.country, s)), + ..._buildTopFilters(context, 'Top places', entryCountPerPlace, (s) => LocationFilter(LocationLevel.place, s)), ..._buildTopFilters(context, 'Top tags', entryCountPerTag, (s) => TagFilter(s)), ], );