Installation status wip

This commit is contained in:
franzap
2024-10-09 11:24:06 -03:00
parent 2b893765fc
commit ab2736f9b2
7 changed files with 146 additions and 120 deletions
+3
View File
@@ -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:
+135 -107
View File
@@ -33,14 +33,6 @@ class App extends BaseApp with DataModelMixin<App> {
final BelongsTo<User> signer;
final BelongsTo<User> 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<App> {
Map<String, dynamic> 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<App> {
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<void> install() async {
if (!canInstall && !canUpdate) {
return;
@@ -143,10 +109,12 @@ class App extends BaseApp with DataModelMixin<App> {
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<App> {
}
mixin AppAdapter on Adapter<App> {
ProviderSubscription? _sub;
@override
Future<void> onInitialized() async {
if (!inIsolate) {
_sub = ref.listen(installedAppProvider, (_, __) {
triggerNotify();
});
}
super.onInitialized();
}
@override
void dispose() {
_sub?.close();
super.dispose();
}
Future<List<App>> loadAppModels(Map<String, dynamic> params) async {
final includes = params.remove('includes') ?? false;
final apps = await super.findAll(params: params);
@@ -232,7 +182,8 @@ mixin AppAdapter on Adapter<App> {
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<App> {
OnSuccessAll<App>? onSuccess,
OnErrorAll<App>? 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<List<App>> findWhereIdInLocal(Iterable<String> appIds) async {
List<App> findWhereIdInLocal(Iterable<String> 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<App> {
return apps.first;
}
// TODO Make this async on writes / sync on reads (and return a class)
Future<Map<String, (String, int)>> getInstalledAppsMap(
{bool defer = false}) async {
late List<PackageInfo>? infos;
if (Platform.isAndroid) {
infos = await packageManager.getInstalledPackages();
} else {
infos = [];
Future<Set<String>> _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<List<App>> updateInstallStatus({App? app}) async {
if (!Platform.isAndroid) {
return [];
}
final updatedApps = <App>[];
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> {
'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<App> 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<String, dynamic>;
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<bool> _isHashMismatch(String path, String hash) async {
@@ -412,9 +441,8 @@ class ErrorInstallProgress extends AppInstallProgress {
ErrorInstallProgress(this.e);
}
final installedAppProvider =
StateProvider<Map<String, (String, int)>>((_) => {});
final installationProgressProvider =
StateProvider.family<AppInstallProgress, String>(
(_, arg) => IdleInstallProgress());
final appsToUpdateProvider = StateProvider((_) => 0);
-1
View File
@@ -26,7 +26,6 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
// 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];
}
+2 -3
View File
@@ -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,
+3 -4
View File
@@ -144,13 +144,12 @@ final dataLibrariesInitializer = FutureProvider<void>((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();
}
},
);
+2 -4
View File
@@ -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(
+1 -1
View File
@@ -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();