device capabilities, API <19 prep

This commit is contained in:
Thibault Deckers 2021-11-28 14:38:17 +09:00
parent c5a4048322
commit 089304da2d
18 changed files with 168 additions and 122 deletions

View file

@ -44,7 +44,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
// channels for analysis
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))

View file

@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
@ -163,11 +163,13 @@ class MainActivity : FlutterActivity() {
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
// save access permissions across reboots
val takeFlags = (data.flags
and (Intent.FLAG_GRANT_READ_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
}
// resume pending action
onStorageAccessResult(requestCode, treeUri)

View file

@ -51,8 +51,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
"openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share)
"canPin" -> safe(call, result, ::canPin)
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
"pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
else -> result.notImplemented()
}
}
@ -323,13 +322,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// shortcuts
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(isPinSupported())
}
private fun pin(call: MethodCall, result: MethodChannel.Result) {
private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument<String>("label")
val iconBytes = call.argument<ByteArray>("iconBytes")
val filters = call.argument<List<String>>("filters")
@ -339,7 +332,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return
}
if (!isPinSupported()) {
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}

View file

@ -1,21 +1,36 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.os.Build
import androidx.core.content.pm.ShortcutManagerCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.util.*
class DeviceHandler : MethodCallHandler {
class DeviceHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getCapabilities" -> safe(call, result, ::getCapabilities)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented()
}
}
private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(hashMapOf(
"canGrantDirectoryAccess" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP),
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT),
// as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage,
// but using hybrid composition would make it usable on API 19 too,
// cf https://github.com/flutter/flutter/issues/23728
"canRenderGoogleMaps" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH),
))
}
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}

View file

@ -73,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.ParseException
import java.util.*
@ -189,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val kv = pair as KeyValuePair
val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
StandardCharsets.UTF_8
} else {
Charset.forName("UTF-8")
}
} else {
kv.value.charset
}
val valueString = String(kv.value.bytes, charset)
val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {

View file

@ -91,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
private fun createFile() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
// TODO TLAD [<=API18] create file
error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
@ -128,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
private fun openFile() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
// TODO TLAD [<=API18] open file
error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
return
}
val mimeType = args["mimeType"] as String?
if (mimeType == null) {
error("openFile-args", "failed because of missing arguments", null)

View file

@ -54,9 +54,11 @@ object MultiPage {
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
}

View file

@ -195,21 +195,31 @@ object PermissionManager {
} ?: false
}
// returns paths matching URIs granted by the user
// returns paths matching directory URIs granted by the user
fun getGrantedDirs(context: Context): Set<String> {
val grantedDirs = HashSet<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
for (uriPermission in context.contentResolver.persistedUriPermissions) {
val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri)
dirPath?.let { grantedDirs.add(it) }
}
}
return grantedDirs
}
// returns paths accessible to the app (granted by the user or by default)
private fun getAccessibleDirs(context: Context): Set<String> {
val accessibleDirs = HashSet(getGrantedDirs(context))
// from Android R, we no longer have access permission by default on primary volume
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
// until API 18 / Android 4.3 / Jelly Bean MR2, removable storage is accessible by default like primary storage
// from API 19 / Android 4.4 / KitKat, removable storage requires access permission, at the file level
// from API 21 / Android 5.0 / Lollipop, removable storage requires access permission, but directory access grant is possible
// from API 30 / Android 11 / R, any storage requires access permission
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR2) {
accessibleDirs.addAll(StorageUtils.getVolumePaths(context))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q
) {
accessibleDirs.add(StorageUtils.getPrimaryVolumePath(context))
}
return accessibleDirs
@ -234,6 +244,7 @@ object PermissionManager {
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
private fun releaseUriPermission(context: Context, it: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.releasePersistableUriPermission(it, flags)

View file

@ -1,7 +1,7 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:github/github.dart';
@ -62,12 +62,8 @@ class LiveAvesAvailability implements AvesAvailability {
@override
Future<bool> get canLocatePlaces => Future.wait<bool>([isConnected, hasPlayServices]).then((results) => results.every((result) => result));
// as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage,
// but using hybrid composition would make it usable on 19 too, cf https://github.com/flutter/flutter/issues/23728
Future<bool> get _isUseGoogleMapRenderingSupported => DeviceInfoPlugin().androidInfo.then((androidInfo) => (androidInfo.version.sdkInt ?? 0) >= 20);
@override
Future<bool> get canUseGoogleMaps => Future.wait<bool>([_isUseGoogleMapRenderingSupported, hasPlayServices]).then((results) => results.every((result) => result));
Future<bool> get canUseGoogleMaps async => device.canRenderGoogleMaps && await hasPlayServices;
@override
Future<bool> get isNewVersionAvailable async {

32
lib/model/device.dart Normal file
View file

@ -0,0 +1,32 @@
import 'package:aves/services/common/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
final Device device = Device._private();
class Device {
late final String _userAgent;
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderGoogleMaps;
String get userAgent => _userAgent;
bool get canGrantDirectoryAccess => _canGrantDirectoryAccess;
bool get canPinShortcut => _canPinShortcut;
bool get canPrint => _canPrint;
bool get canRenderGoogleMaps => _canRenderGoogleMaps;
Device._private();
Future<void> init() async {
final packageInfo = await PackageInfo.fromPlatform();
_userAgent = '${packageInfo.packageName}/${packageInfo.version}';
final capabilities = await deviceService.getCapabilities();
_canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false;
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canPrint = capabilities['canPrint'] ?? false;
_canRenderGoogleMaps = capabilities['canRenderGoogleMaps'] ?? false;
}
}

View file

@ -6,7 +6,6 @@ import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
@ -29,8 +28,6 @@ abstract class AndroidAppService {
Future<bool> shareSingle(String uri, String mimeType);
Future<bool> canPinToHomeScreen();
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri});
}
@ -174,25 +171,6 @@ class PlatformAndroidAppService implements AndroidAppService {
// app shortcuts
// this ability will not change over the lifetime of the app
bool? _canPin;
@override
Future<bool> canPinToHomeScreen() async {
if (_canPin != null) return SynchronousFuture(_canPin!);
try {
final result = await platform.invokeMethod('canPin');
if (result != null) {
_canPin = result;
return result;
}
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@override
Future<void> pinToHomeScreen(String label, AvesEntry? coverEntry, {Set<CollectionFilter>? filters, String? uri}) async {
Uint8List? iconBytes;
@ -209,7 +187,7 @@ class PlatformAndroidAppService implements AndroidAppService {
);
}
try {
await platform.invokeMethod('pin', <String, dynamic>{
await platform.invokeMethod('pinShortcut', <String, dynamic>{
'label': label,
'iconBytes': iconBytes,
'filters': filters?.map((filter) => filter.toJson()).toList(),

View file

@ -2,6 +2,8 @@ import 'package:aves/services/common/services.dart';
import 'package:flutter/services.dart';
abstract class DeviceService {
Future<Map<String, dynamic>> getCapabilities();
Future<String?> getDefaultTimeZone();
Future<int> getPerformanceClass();
@ -10,6 +12,17 @@ abstract class DeviceService {
class PlatformDeviceService implements DeviceService {
static const platform = MethodChannel('deckers.thibault/aves/device');
@override
Future<Map<String, dynamic>> getCapabilities() async {
try {
final result = await platform.invokeMethod('getCapabilities');
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<String?> getDefaultTimeZone() async {
try {

View file

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:aves/app_flavor.dart';
import 'package:aves/app_mode.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/accessibility_animations.dart';
import 'package:aves/model/settings/screen_on.dart';
import 'package:aves/model/settings/settings.dart';
@ -28,15 +29,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:overlay_support/overlay_support.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class AvesApp extends StatefulWidget {
final AppFlavor flavor;
static String userAgent = '';
const AvesApp({
Key? key,
required this.flavor,
@ -165,7 +163,7 @@ class _AvesAppState extends State<AvesApp> {
isRotationLocked: await windowService.isRotationLocked(),
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
);
unawaited(_initUserAgent());
await device.init();
FijkLog.setLevel(FijkLogLevel.Warn);
// keep screen on
@ -210,11 +208,6 @@ class _AvesAppState extends State<AvesApp> {
];
}
Future<void> _initUserAgent() async {
final info = await PackageInfo.fromPlatform();
AvesApp.userAgent = '${info.packageName}/${info.version}';
}
void _onNewIntent(Map? intentData) {
debugPrint('$runtimeType onNewIntent with intentData=$intentData');

View file

@ -10,7 +10,6 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
@ -46,7 +45,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final List<StreamSubscription> _subscriptions = [];
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
final FocusNode _queryBarFocusNode = FocusNode();
late final Listenable _queryFocusRequestNotifier;
@ -69,7 +67,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
vsync: this,
);
_isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
_registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
}
@ -104,10 +101,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return FutureBuilder<bool>(
future: _canAddShortcutsLoader,
builder: (context, snapshot) {
final canAddShortcuts = snapshot.data ?? false;
return Selector<Selection<AvesEntry>, Tuple2<bool, int>>(
selector: (context, selection) => Tuple2(selection.isSelecting, selection.selectedItems.length),
builder: (context, s, child) {
@ -127,7 +120,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
actions: _buildActions(
isSelecting: isSelecting,
selectedItemCount: selectedItemCount,
supportShortcuts: canAddShortcuts,
),
bottom: PreferredSize(
preferredSize: Size.fromHeight(appBarBottomHeight),
@ -156,8 +148,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
);
},
);
},
);
}
double get appBarBottomHeight {
@ -214,14 +204,12 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
List<Widget> _buildActions({
required bool isSelecting,
required int selectedItemCount,
required bool supportShortcuts,
}) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
bool isVisible(EntrySetAction action) => _actionDelegate.isVisible(
action,
appMode: appMode,
isSelecting: isSelecting,
supportShortcuts: supportShortcuts,
sortFactor: collection.sortFactor,
itemCount: collection.entryCount,
selectedItemCount: selectedItemCount,

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_xmp_iptc.dart';
import 'package:aves/model/filters/album.dart';
@ -44,7 +45,6 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
EntrySetAction action, {
required AppMode appMode,
required bool isSelecting,
required bool supportShortcuts,
required EntrySortFactor sortFactor,
required int itemCount,
required int selectedItemCount,
@ -67,7 +67,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
case EntrySetAction.toggleTitleSearch:
return !isSelecting;
case EntrySetAction.addShortcut:
return appMode == AppMode.main && !isSelecting && supportShortcuts;
return appMode == AppMode.main && !isSelecting && device.canPinShortcut;
// browsing or selecting
case EntrySetAction.map:
case EntrySetAction.stats:

View file

@ -1,4 +1,4 @@
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/model/device.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:provider/provider.dart';
@ -53,7 +53,7 @@ class StamenWatercolorLayer extends StatelessWidget {
class _NetworkTileProvider extends NetworkTileProvider {
final Map<String, String> headers = {
'User-Agent': AvesApp.userAgent,
'User-Agent': device.userAgent,
};
_NetworkTileProvider();

View file

@ -1,4 +1,5 @@
import 'package:aves/app_flavor.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
@ -63,7 +64,7 @@ class PrivacySection extends StatelessWidget {
),
),
const HiddenItemsTile(),
const StorageAccessTile(),
if (device.canGrantDirectoryAccess) const StorageAccessTile(),
],
);
}

View file

@ -1,4 +1,5 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.dart';
@ -79,7 +80,7 @@ class ViewerTopOverlay extends StatelessWidget {
return targetEntry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.print:
return !targetEntry.isVideo;
return !targetEntry.isVideo && device.canPrint;
case EntryAction.openMap:
return targetEntry.hasGps;
case EntryAction.viewSource: