diff --git a/analysis_options.yaml b/analysis_options.yaml index 16a6a20..74ee47f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,9 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + no_leading_underscores_for_local_identifiers: ignore include: package:flutter_lints/flutter.yaml linter: diff --git a/lib/models/app.dart b/lib/models/app.dart index c2b172b..4772940 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -33,14 +33,6 @@ class App extends BaseApp with DataModelMixin { final BelongsTo signer; final BelongsTo developer; - App( - {super.createdAt, - super.content, - super.tags, - required this.developer, - required this.releases, - required this.signer}); - App.fromJson(super.map) : developer = belongsTo(map['developer']), releases = hasMany(map['releases']), @@ -49,21 +41,22 @@ class App extends BaseApp with DataModelMixin { Map toJson() => super.toMap(); - String? get installedVersion => DataModel.adapterFor(this) - .ref - .read(installedAppProvider)[id!.toString()] - ?.$1; - - int? get installedVersionCode => DataModel.adapterFor(this) - .ref - .read(installedAppProvider)[id!.toString()] - ?.$2; + String? get installedVersion => tagMap['installedVersion']?.firstOrNull; + int? get installedVersionCode => + int.tryParse(tagMap['installedVersionCode']?.firstOrNull ?? ''); + AppInstallStatus? get status { + final _status = tagMap['status']?.firstOrNull; + if (_status == null) return null; + return AppInstallStatus.values.firstWhereOrNull((e) => e.name == _status); + } FileMetadata? get latestMetadata { return releases.ordered.firstOrNull?.artifacts .where((a) => - a.mimeType == 'application/vnd.android.package-archive' && - a.platforms.contains('android-arm64-v8a')) + a.mimeType == kAndroidMimeType && + (a.platforms.contains('android-arm64-v8a') || + // TODO: arch tag deprecated, remove in future release + (a.tagMap['arch'] ?? {}).contains('arm64-v8a'))) .firstOrNull; } @@ -71,33 +64,6 @@ class App extends BaseApp with DataModelMixin { bool get canUpdate => status == AppInstallStatus.updatable; bool get isUpdated => status == AppInstallStatus.updated; - AppInstallStatus get status { - if (releases.isEmpty) { - return AppInstallStatus.loading; - } - if (releases.isNotEmpty && latestMetadata == null) { - return AppInstallStatus.differentArchitecture; - } - if (installedVersion == null) { - return AppInstallStatus.installable; - } - var comp = 0; - if (latestMetadata!.versionCode != null && id != 'store.zap.app') { - // Note: need to exclude zap.store because development versions always - // carry a lower version code (e.g. 12) than published ones (e.g. 2012) - comp = latestMetadata!.versionCode!.compareTo(installedVersionCode!); - } - - if (comp == 0) { - comp = latestMetadata!.version!.compareTo(installedVersion!); - } - - if (comp == 1) return AppInstallStatus.updatable; - if (comp == 0) return AppInstallStatus.updated; - // else it's a downgrade, which is not installable - return AppInstallStatus.downgrade; - } - Future install() async { if (!canInstall && !canUpdate) { return; @@ -143,10 +109,12 @@ class App extends BaseApp with DataModelMixin { final result = await InstallPlugin.install(file.path); if (result['isSuccess']) { await file.delete(); - await adapter.getInstalledAppsMap(); - saveLocal(); + await adapter.updateInstallStatus(app: this); + notifier.state = IdleInstallProgress(); + } else { + const msg = 'Android rejected installation'; + notifier.state = ErrorInstallProgress(Exception(msg)); } - notifier.state = IdleInstallProgress(); } if (await file.exists()) { @@ -203,24 +171,6 @@ class App extends BaseApp with DataModelMixin { } mixin AppAdapter on Adapter { - ProviderSubscription? _sub; - - @override - Future onInitialized() async { - if (!inIsolate) { - _sub = ref.listen(installedAppProvider, (_, __) { - triggerNotify(); - }); - } - super.onInitialized(); - } - - @override - void dispose() { - _sub?.close(); - super.dispose(); - } - Future> loadAppModels(Map params) async { final includes = params.remove('includes') ?? false; final apps = await super.findAll(params: params); @@ -232,7 +182,8 @@ mixin AppAdapter on Adapter { await ref.fileMetadata.findAll(params: { 'ids': metadataIds, - '#m': [kAndroidMimeType] + '#m': [kAndroidMimeType], + // TODO: '#f': ['android-arm64-v8a'], }); if (includes) { @@ -255,20 +206,20 @@ mixin AppAdapter on Adapter { OnSuccessAll? onSuccess, OnErrorAll? onError, DataRequestLabel? label}) async { - final map = await getInstalledAppsMap(defer: true); - if (params!.containsKey('installed')) { - if (map.keys.isNotEmpty) { - params['#d'] = map.keys; + final appIds = await _installedIdentifiers(); + if (appIds.isNotEmpty) { + params['#d'] = appIds; params.remove('installed'); - print('filtering by installed ${params['#d']}'); - return await loadAppModels(params); + await loadAppModels(params); + // Once apps are loaded, check for installed status + return await updateInstallStatus(); } } return await loadAppModels(params); } - Future> findWhereIdInLocal(Iterable appIds) async { + List findWhereIdInLocal(Iterable appIds) { final result = db.select( 'SELECT key, data, json_extract(data, \'\$.id\') AS id from apps where id in (${appIds.map((_) => '?').join(', ')})', appIds.toList()); @@ -296,15 +247,36 @@ mixin AppAdapter on Adapter { return apps.first; } - // TODO Make this async on writes / sync on reads (and return a class) - Future> getInstalledAppsMap( - {bool defer = false}) async { - late List? infos; - if (Platform.isAndroid) { - infos = await packageManager.getInstalledPackages(); - } else { - infos = []; + Future> _installedIdentifiers() async { + if (!Platform.isAndroid) { + return {}; } + final infos = await packageManager.getInstalledPackages(); + return { + for (final i in infos!) + if (i.packageName != null && + ![ + 'android', + 'com.android', + 'com.google', + 'org.chromium.webview_shell', + 'app.grapheneos', + 'app.vanadium' + ].any((e) => i.packageName!.startsWith(e))) + i.packageName!, + }; + } + + Future> updateInstallStatus({App? app}) async { + if (!Platform.isAndroid) { + return []; + } + + final updatedApps = []; + final infos = app != null + ? [await packageManager.getPackageInfo(packageName: app.id.toString())] + .nonNulls + : await packageManager.getInstalledPackages(); final installedPackageInfos = infos!.where((i) => ![ 'android', @@ -315,43 +287,100 @@ mixin AppAdapter on Adapter { 'app.vanadium' ].any((e) => i.packageName!.startsWith(e))); - final newState = { - for (final info in installedPackageInfos) - info.packageName!: (info.versionName ?? '?', info.versionCode!) - }; + final apps = findManyLocalByIds( + installedPackageInfos.map((i) => i.packageName).nonNulls); - // Providers can't set other providers state - // while initializing, so defer setting state - if (defer) { - Future.microtask(() { - ref.read(installedAppProvider.notifier).state = newState; - }); - return newState; + for (final i in installedPackageInfos) { + var app = apps.firstWhereOrNull((app) => i.packageName == app.id); + final installedVersion = i.versionName; + final installedVersionCode = i.versionCode; + + if (app == null) continue; + + final AppInstallStatus status = + _determineInstallStatus(app, installedVersion, installedVersionCode); + + if (app.installedVersionCode != installedVersionCode) { + app.addTags({ + ('installedVersion', installedVersion), + ('installedVersionCode', installedVersionCode), + ('status', status.name) + }, replace: true); + app.saveLocal(); + } + + updatedApps.add(app); } - return ref.read(installedAppProvider.notifier).state = newState; + // Update number of apps + final rs = db.select( + 'SELECT count(*) as c FROM apps, json_each(json_extract(apps.data, \'\$.tags\')) WHERE json_extract(value, \'\$[0]\') is \'status\' AND json_extract(value, \'\$[1]\') is \'updatable\''); + ref.read(appsToUpdateProvider.notifier).state = rs.first['c']; + + return updatedApps; + } + + AppInstallStatus _determineInstallStatus( + App app, String? installedVersion, int? installedVersionCode) { + if (app.releases.isEmpty) { + return AppInstallStatus.loading; + } + + if (app.releases.isNotEmpty && app.latestMetadata == null) { + return AppInstallStatus.differentArchitecture; + } + if (installedVersion == null) { + return AppInstallStatus.installable; + } + var comp = 0; + if (app.latestMetadata!.versionCode != null && + installedVersionCode != null && + app.id != 'store.zap.app') { + // Note: need to exclude zap.store because development versions always + // carry a lower version code (e.g. 12) than published ones (e.g. 2012) + comp = app.latestMetadata!.versionCode!.compareTo(installedVersionCode); + } + + if (comp == 0) { + comp = app.latestMetadata!.version!.compareTo(installedVersion); + } + + if (comp == 1) return AppInstallStatus.updatable; + if (comp == 0) return AppInstallStatus.updated; + // else it's a downgrade, which is not installable + return AppInstallStatus.downgrade; } @override DeserializedData deserialize(Object? data, {String? key}) { final list = data is Iterable ? data : [data as Map]; + for (final e in list) { + e['id'] = (e['tags'] as Iterable).where((t) => t[0] == 'd').first.last; + } + + final savedApps = findManyLocalByIds(list.map((e) => e['id'])); for (final e in list) { final map = e as Map; + final tags = map['tags'] as Iterable; map['signer'] = map['pubkey']; - final zapTags = (map['tags'] as Iterable).where((t) => t[0] == 'zap'); + final zapTags = tags.where((t) => t[0] == 'zap'); if (zapTags.length == 1) { map['developer'] = (zapTags.first as List)[1]; } } - return super.deserialize(data); - } - // @override - // App deserializeLocal(map, {String? key}) { - // map = transformDeserialize(map); - // print('deserializing local with key $key'); - // return internalWrapStopInit(() => App.fromJson(map), key: key); - // } + final deserialized = super.deserialize(data); + // Preserve installation data + for (final app in deserialized.models) { + final savedApp = savedApps.firstWhereOrNull((s) => s.id == app.id); + app.addTags({ + ('installedVersion', savedApp?.installedVersion), + ('installedVersionCode', savedApp?.installedVersionCode), + ('status', savedApp?.status?.name) + }, replace: true); + } + return deserialized; + } } Future _isHashMismatch(String path, String hash) async { @@ -412,9 +441,8 @@ class ErrorInstallProgress extends AppInstallProgress { ErrorInstallProgress(this.e); } -final installedAppProvider = - StateProvider>((_) => {}); - final installationProgressProvider = StateProvider.family( (_, arg) => IdleInstallProgress()); + +final appsToUpdateProvider = StateProvider((_) => 0); diff --git a/lib/models/nostr_adapter.dart b/lib/models/nostr_adapter.dart index 9c6172e..a5a558f 100644 --- a/lib/models/nostr_adapter.dart +++ b/lib/models/nostr_adapter.dart @@ -26,7 +26,6 @@ mixin NostrAdapter> on Adapter { // ID should be the identifier in PREs final dTags = (map['tags'] as Iterable).where((t) => t[0] == 'd'); - // TODO: This eventually should become $pubkey-$appid (just like zapstore-cli) if (dTags.length == 1) { map['id'] = (dTags.first as List)[1]; } diff --git a/lib/navigation/mobile_scaffold.dart b/lib/navigation/mobile_scaffold.dart index ba7f151..7c101b8 100644 --- a/lib/navigation/mobile_scaffold.dart +++ b/lib/navigation/mobile_scaffold.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:zapstore/models/app.dart'; import 'package:zapstore/utils/theme.dart'; import 'package:zapstore/widgets/drawer_container.dart'; @@ -16,9 +17,7 @@ class MobileScaffold extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // TODO Inefficient, should be able to query for this - // (need to store app status in database, local only) - const appsToUpdate = 0; + final appsToUpdate = ref.watch(appsToUpdateProvider); return Scaffold( key: scaffoldKey, diff --git a/lib/navigation/router.dart b/lib/navigation/router.dart index eac840a..a2f9fe0 100644 --- a/lib/navigation/router.dart +++ b/lib/navigation/router.dart @@ -144,13 +144,12 @@ final dataLibrariesInitializer = FutureProvider((ref) async { ref.read(relayMessageNotifierProvider(kSocialRelays).notifier); await Future.wait([relay.initialize(), socialRelays.initialize()]); - // Listen to app lifecycle changes + // Trigger app install status calculations + await ref.apps.appAdapter.updateInstallStatus(); _lifecycleListener = AppLifecycleListener( onStateChange: (state) async { if (state == AppLifecycleState.resumed) { - final adapter = ref.apps.appAdapter; - await adapter.getInstalledAppsMap(); - adapter.triggerNotify(); + await ref.apps.appAdapter.updateInstallStatus(); } }, ); diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index b0c7a01..782e7ab 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -31,13 +31,11 @@ class AppDetailScreen extends HookConsumerWidget { alsoWatch: (_) => {_.releases, _.releases.artifacts, _.signer, _.developer}); - // TODO: Why this? and not remote just above? + // TODO: Workaround for bug in watchAll (when remote=true) + // TODO: Just fetch the signer, no need to refetch again?? useFuture(useMemoized(() => ref.apps.findOne(model.id!, remote: true, params: {'includes': true}))); - // TODO: Hack to refresh on install changes - final _ = ref.watch(installedAppProvider); - final app = state.model ?? model; return RefreshIndicator( diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 46a432e..d521282 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -13,7 +13,7 @@ class UpdatesScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // TODO Workaround for bug in watchAll (when remote=true) + // TODO: Workaround for bug in watchAll (when remote=true) final snapshot = useFuture(useMemoized( () => ref.apps.findAll(remote: true, params: {'installed': true}))); final state = ref.apps.watchAll();