Data retrieval revamp, local apps

This commit is contained in:
franzap
2024-10-12 14:22:12 -03:00
parent 2abc6d360a
commit 656aedc87f
16 changed files with 321 additions and 191 deletions
+4
View File
@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:zapstore/models/app_curation_set.dart'; import 'package:zapstore/models/app_curation_set.dart';
import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/user.dart';
@@ -17,6 +18,7 @@ final adapterProvidersMap = <String, Provider<Adapter<DataModelMixin>>>{
'appCurationSets': appCurationSetsAdapterProvider, 'appCurationSets': appCurationSetsAdapterProvider,
'apps': appsAdapterProvider, 'apps': appsAdapterProvider,
'fileMetadata': fileMetadataAdapterProvider, 'fileMetadata': fileMetadataAdapterProvider,
'localApps': localAppsAdapterProvider,
'releases': releasesAdapterProvider, 'releases': releasesAdapterProvider,
'settings': settingsAdapterProvider, 'settings': settingsAdapterProvider,
'users': usersAdapterProvider 'users': usersAdapterProvider
@@ -26,6 +28,7 @@ extension AdapterWidgetRefX on WidgetRef {
Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch; Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch;
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch; Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch;
Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch; Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch;
Adapter<LocalApp> get localApps => watch(localAppsAdapterProvider)..internalWatch = watch;
Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch; Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch;
Adapter<Settings> get settings => watch(settingsAdapterProvider)..internalWatch = watch; Adapter<Settings> get settings => watch(settingsAdapterProvider)..internalWatch = watch;
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch; Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch;
@@ -36,6 +39,7 @@ extension AdapterRefX on Ref {
Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch as Watcher; Adapter<AppCurationSet> get appCurationSets => watch(appCurationSetsAdapterProvider)..internalWatch = watch as Watcher;
Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch as Watcher; Adapter<App> get apps => watch(appsAdapterProvider)..internalWatch = watch as Watcher;
Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch as Watcher; Adapter<FileMetadata> get fileMetadata => watch(fileMetadataAdapterProvider)..internalWatch = watch as Watcher;
Adapter<LocalApp> get localApps => watch(localAppsAdapterProvider)..internalWatch = watch as Watcher;
Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch as Watcher; Adapter<Release> get releases => watch(releasesAdapterProvider)..internalWatch = watch as Watcher;
Adapter<Settings> get settings => watch(settingsAdapterProvider)..internalWatch = watch as Watcher; Adapter<Settings> get settings => watch(settingsAdapterProvider)..internalWatch = watch as Watcher;
Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch as Watcher; Adapter<User> get users => watch(usersAdapterProvider)..internalWatch = watch as Watcher;
+55 -147
View File
@@ -15,6 +15,7 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.data.dart'; import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/file_metadata.dart'; import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/user.dart';
@@ -25,31 +26,22 @@ import 'package:zapstore/utils/system_info.dart';
part 'app.g.dart'; part 'app.g.dart';
const kAndroidMimeType = 'application/vnd.android.package-archive';
@DataAdapter([NostrAdapter, AppAdapter]) @DataAdapter([NostrAdapter, AppAdapter])
class App extends BaseApp with DataModelMixin<App> { class App extends BaseApp with DataModelMixin<App> {
final HasMany<Release> releases; final HasMany<Release> releases;
final BelongsTo<User> signer; final BelongsTo<User> signer;
final BelongsTo<User> developer; final BelongsTo<User> developer;
final BelongsTo<LocalApp> localApp;
App.fromJson(super.map) App.fromJson(super.map)
: developer = belongsTo(map['developer']), : developer = belongsTo(map['developer']),
releases = hasMany(map['releases']), releases = hasMany(map['releases']),
signer = belongsTo(map['signer']), signer = belongsTo(map['signer']),
localApp = belongsTo(map['localApp']),
super.fromJson(); super.fromJson();
Map<String, dynamic> toJson() => super.toMap(); Map<String, dynamic> toJson() => super.toMap();
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 { FileMetadata? get latestMetadata {
return releases.ordered.firstOrNull?.artifacts return releases.ordered.firstOrNull?.artifacts
.where((a) => .where((a) =>
@@ -58,9 +50,9 @@ class App extends BaseApp with DataModelMixin<App> {
.firstOrNull; .firstOrNull;
} }
bool get canInstall => status == AppInstallStatus.installable; bool get canInstall => localApp.value?.status == AppInstallStatus.installable;
bool get canUpdate => status == AppInstallStatus.updatable; bool get canUpdate => localApp.value?.status == AppInstallStatus.updatable;
bool get isUpdated => status == AppInstallStatus.updated; bool get isUpdated => localApp.value?.status == AppInstallStatus.updated;
Future<void> install() async { Future<void> install() async {
if (!canInstall && !canUpdate) { if (!canInstall && !canUpdate) {
@@ -107,7 +99,8 @@ class App extends BaseApp with DataModelMixin<App> {
final result = await InstallPlugin.install(file.path); final result = await InstallPlugin.install(file.path);
if (result['isSuccess']) { if (result['isSuccess']) {
await file.delete(); await file.delete();
await adapter.updateInstallStatus(app: this); await adapter.ref.localApps.localAppAdapter
.updateInstallStatus(appId: id?.toString());
notifier.state = IdleInstallProgress(); notifier.state = IdleInstallProgress();
} else { } else {
const msg = 'Android rejected installation'; const msg = 'Android rejected installation';
@@ -171,10 +164,22 @@ class App extends BaseApp with DataModelMixin<App> {
mixin AppAdapter on Adapter<App> { mixin AppAdapter on Adapter<App> {
Future<List<App>> loadAppModels(Map<String, dynamic> params) async { Future<List<App>> loadAppModels(Map<String, dynamic> params) async {
final includes = params.remove('includes') ?? false; final includes = params.remove('includes') ?? false;
final apps = await super.findAll(params: params);
final releases = await ref.releases.findAll(params: { final apps = await super.findAll(
'#a': apps.map((app) => app.getReplaceableEventLink().formatted) params: {
}); ...params,
'#f': ['android-arm64-v8a'],
},
);
// Find all appid@version ($3) as we need to pick one tag to query on
// (filters by kind ($1) and pubkey ($2) done locally)
final releases = await ref.releases.findAll(
params: {
'#d': apps
.map((app) => app.linkedReplaceableEvents.firstOrNull?.$3)
.nonNulls
},
);
final metadataIds = final metadataIds =
releases.map((r) => r.tagMap['e']).nonNulls.expand((_) => _); releases.map((r) => r.tagMap['e']).nonNulls.expand((_) => _);
@@ -209,17 +214,19 @@ mixin AppAdapter on Adapter<App> {
if (appIds.isNotEmpty) { if (appIds.isNotEmpty) {
params['#d'] = appIds; params['#d'] = appIds;
params.remove('installed'); params.remove('installed');
await loadAppModels(params); final apps = await loadAppModels(params);
// Once apps are loaded, check for installed status // Once apps are loaded, check for installed status
return await updateInstallStatus(); await ref.localApps.localAppAdapter.updateInstallStatus();
return apps;
} }
} }
return await loadAppModels(params); return await loadAppModels(params);
} }
List<App> findWhereIdInLocal(Iterable<String> appIds) { List<App> findWhereIdInLocal(Iterable<String> appIds) {
const len = 5 + 64 + 3; // kind + pubkey + separators length
final result = db.select( final result = db.select(
'SELECT key, data, json_extract(data, \'\$.id\') AS id from apps where id in (${appIds.map((_) => '?').join(', ')})', 'SELECT key, data, substr(json_extract(data, \'\$.id\'), $len) AS id from apps where id in (${appIds.map((_) => '?').join(', ')})',
appIds.toList()); appIds.toList());
return deserializeFromResult(result); return deserializeFromResult(result);
} }
@@ -242,6 +249,8 @@ mixin AppAdapter on Adapter<App> {
deleteLocalById(id); deleteLocalById(id);
return null; return null;
} }
await ref.localApps.localAppAdapter
.updateInstallStatus(appId: id.toString());
return apps.first; return apps.first;
} }
@@ -253,131 +262,26 @@ mixin AppAdapter on Adapter<App> {
return { return {
for (final i in infos!) for (final i in infos!)
if (i.packageName != null && if (i.packageName != null &&
![ !kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)))
'android',
'com.android',
'com.google',
'org.chromium.webview_shell',
'app.grapheneos',
'app.vanadium'
].any((e) => i.packageName!.startsWith(e)))
i.packageName!, 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',
'com.android',
'com.google',
'org.chromium.webview_shell',
'app.grapheneos',
'app.vanadium'
].any((e) => i.packageName!.startsWith(e)));
final apps = findManyLocalByIds(
installedPackageInfos.map((i) => i.packageName).nonNulls);
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);
}
// 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 @override
DeserializedData<App> deserialize(Object? data, {String? key}) { DeserializedData<App> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map]; 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 Map<String, dynamic> map in list) {
for (final e in list) { final tagMap = tagsToMap(map['tags']);
final map = e as Map<String, dynamic>; final appId = tagMap['d']!.first;
final tags = map['tags'] as Iterable; map['id'] =
(map['kind'] as int, map['pubkey'].toString(), appId).formatted;
map['signer'] = map['pubkey']; map['signer'] = map['pubkey'];
final zapTags = tags.where((t) => t[0] == 'zap'); map['developer'] = tagMap['zap']?.firstOrNull?[1];
if (zapTags.length == 1) { map['localApp'] = appId;
map['developer'] = (zapTags.first as List)[1];
}
} }
final deserialized = super.deserialize(data); return 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;
} }
} }
@@ -408,17 +312,6 @@ Future<bool> _isHashMismatch(String path, String hash) async {
}); });
} }
// install support
enum AppInstallStatus {
updated,
updatable,
installable,
downgrade,
differentArchitecture,
loading
}
// class // class
sealed class AppInstallProgress {} sealed class AppInstallProgress {}
@@ -444,3 +337,18 @@ final installationProgressProvider =
(_, arg) => IdleInstallProgress()); (_, arg) => IdleInstallProgress());
final appsToUpdateProvider = StateProvider((_) => 0); final appsToUpdateProvider = StateProvider((_) => 0);
// Constants
const kAndroidMimeType = 'application/vnd.android.package-archive';
const kExcludedAppIdNamespaces = [
'android',
'com.android',
'com.google',
'com.samsung',
'com.sec.android',
'org.chromium.webview_shell',
'app.grapheneos',
'app.vanadium',
];
+13
View File
@@ -28,6 +28,12 @@ mixin _$AppAdapter on Adapter<App> {
type: 'users', type: 'users',
kind: 'BelongsTo', kind: 'BelongsTo',
instance: (_) => (_ as App).developer, instance: (_) => (_ as App).developer,
),
'localApp': RelationshipMeta<LocalApp>(
name: 'localApp',
type: 'localApps',
kind: 'BelongsTo',
instance: (_) => (_ as App).localApp,
) )
}; };
@@ -81,4 +87,11 @@ extension AppRelationshipGraphNodeX on RelationshipGraphNode<App> {
return meta.clone( return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null); parent: this is RelationshipMeta ? this as RelationshipMeta : null);
} }
RelationshipGraphNode<LocalApp> get localApp {
final meta = _$AppAdapter._kAppRelationshipMetas['localApp']
as RelationshipMeta<LocalApp>;
return meta.clone(
parent: this is RelationshipMeta ? this as RelationshipMeta : null);
}
} }
+15 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/models/release.dart'; import 'package:zapstore/models/release.dart';
import 'package:zapstore/models/user.dart'; import 'package:zapstore/models/user.dart';
@@ -42,11 +43,21 @@ mixin FileMetadataAdapter on Adapter<FileMetadata> {
for (final e in list) { for (final e in list) {
final map = e as Map<String, dynamic>; final map = e as Map<String, dynamic>;
map['author'] = map['pubkey']; map['author'] = map['pubkey'];
// Ensure tags are strings
for (var t in map['tags']) {
t[1] = t[1].toString();
}
} }
return super.deserialize(data); return super.deserialize(data);
} }
Future<List<FileMetadata>> zzz(List<String> appIds) async {
final req = RelayRequest(
kinds: {1063},
tags: {
'#m': [kAndroidMimeType],
'#f': ['android-arm64-v8a'],
},
);
final result = await (this as NostrAdapter).relay.queryRaw(req);
final deserialized = await deserializeAsync(result, save: true);
return deserialized.models;
}
} }
+128
View File
@@ -0,0 +1,128 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter_data/flutter_data.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/utils/system_info.dart';
part 'local_app.g.dart';
@DataAdapter([LocalAppAdapter])
@JsonSerializable()
class LocalApp extends DataModel<LocalApp> {
@override
final Object? id;
final String? installedVersion;
final int? installedVersionCode;
final AppInstallStatus? status;
// final String? apkSignatureHash;
LocalApp(
{required this.id,
this.installedVersion,
this.installedVersionCode,
this.status});
LocalApp copyWith(
{String? installedVersion,
int? installedVersionCode,
AppInstallStatus? status}) {
return LocalApp(
id: id,
installedVersion: installedVersion ?? this.installedVersion,
installedVersionCode: installedVersionCode ?? this.installedVersionCode,
status: status ?? this.status,
);
}
}
mixin LocalAppAdapter on Adapter<LocalApp> {
Future<void> updateInstallStatus({String? appId}) async {
if (!Platform.isAndroid) {
return;
}
final infos = appId != null
? [await packageManager.getPackageInfo(packageName: appId)].nonNulls
: await packageManager.getInstalledPackages();
final installedPackageInfos = infos!.where((i) =>
!kExcludedAppIdNamespaces.any((e) => i.packageName!.startsWith(e)));
final localApps = findManyLocalByIds(
installedPackageInfos.map((i) => i.packageName).nonNulls);
for (final i in installedPackageInfos) {
final appId = i.packageName!;
final localApp = localApps.firstWhereOrNull((app) => appId == app.id) ??
LocalApp(id: i.packageName!);
final installedVersion = i.versionName;
final installedVersionCode = i.versionCode;
final app = ref.apps.appAdapter.findWhereIdInLocal([appId]).firstOrNull;
final status =
determineInstallStatus(app, installedVersion, installedVersionCode);
localApp
.copyWith(
installedVersion: installedVersion,
installedVersionCode: installedVersionCode,
status: status)
.saveLocal();
}
// Update number of apps
final rs = db.select(
'SELECT count(*) as c FROM localApps WHERE json_extract(data, \'\$.status\') is \'updatable\'');
ref.read(appsToUpdateProvider.notifier).state = rs.first['c'];
}
AppInstallStatus? determineInstallStatus(
App? app, String? installedVersion, int? installedVersionCode) {
if (app == null || app.releases.isEmpty || app.latestMetadata == null) {
return null;
}
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
LocalApp deserializeLocal(Map<String, dynamic> map, {String? key}) {
// TODO: implement deserializeLocal
return super.deserializeLocal(map, key: key);
}
@override
Map<String, dynamic> serializeLocal(LocalApp model,
{bool withRelationships = true}) {
final z = super.serializeLocal(model, withRelationships: withRelationships);
return z;
}
}
enum AppInstallStatus {
updated,
updatable,
installable,
downgrade,
}
+68
View File
@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'local_app.dart';
// **************************************************************************
// AdapterGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, duplicate_ignore
mixin _$LocalAppAdapter on Adapter<LocalApp> {
static final Map<String, RelationshipMeta> _kLocalAppRelationshipMetas = {};
@override
Map<String, RelationshipMeta> get relationshipMetas =>
_kLocalAppRelationshipMetas;
@override
LocalApp deserializeLocal(map, {String? key}) {
map = transformDeserialize(map);
return internalWrapStopInit(() => _$LocalAppFromJson(map), key: key);
}
@override
Map<String, dynamic> serializeLocal(model, {bool withRelationships = true}) {
final map = _$LocalAppToJson(model);
return transformSerialize(map, withRelationships: withRelationships);
}
}
final _localAppsFinders = <String, dynamic>{};
class $LocalAppAdapter = Adapter<LocalApp>
with _$LocalAppAdapter, LocalAppAdapter;
final localAppsAdapterProvider = Provider<Adapter<LocalApp>>(
(ref) => $LocalAppAdapter(ref, InternalHolder(_localAppsFinders)));
extension LocalAppAdapterX on Adapter<LocalApp> {
LocalAppAdapter get localAppAdapter => this as LocalAppAdapter;
}
extension LocalAppRelationshipGraphNodeX on RelationshipGraphNode<LocalApp> {}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LocalApp _$LocalAppFromJson(Map<String, dynamic> json) => LocalApp(
id: json['id'],
installedVersion: json['installedVersion'] as String?,
installedVersionCode: (json['installedVersionCode'] as num?)?.toInt(),
status: $enumDecodeNullable(_$AppInstallStatusEnumMap, json['status']),
);
Map<String, dynamic> _$LocalAppToJson(LocalApp instance) => <String, dynamic>{
'id': instance.id,
'installedVersion': instance.installedVersion,
'installedVersionCode': instance.installedVersionCode,
'status': _$AppInstallStatusEnumMap[instance.status],
};
const _$AppInstallStatusEnumMap = {
AppInstallStatus.updated: 'updated',
AppInstallStatus.updatable: 'updatable',
AppInstallStatus.installable: 'installable',
AppInstallStatus.downgrade: 'downgrade',
};
+12 -5
View File
@@ -2,7 +2,7 @@ import 'package:flutter_data/flutter_data.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
// NOTE: Very important to use const in relay args to preserve equality in Riverpod families // NOTE: Very important to use const in relay args to preserve equality in Riverpod families
const kAppRelays = ['wss://relay.zap.store']; const kAppRelays = ['ws://10.0.2.2:3000'];
const kSocialRelays = ['wss://relay.primal.net', 'wss://relay.nostr.band']; const kSocialRelays = ['wss://relay.primal.net', 'wss://relay.nostr.band'];
mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> { mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
@@ -24,10 +24,15 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
for (final e in list) { for (final e in list) {
final map = e as Map<String, dynamic>; final map = e as Map<String, dynamic>;
// ID should be the identifier in PREs // ID should be the replaceable link/reference so as to make it replaceable in local db too
final dTags = (map['tags'] as Iterable).where((t) => t[0] == 'd'); final tagMap = tagsToMap(map['tags']);
if (dTags.length == 1) { final isReplaceable = tagMap.containsKey('d');
map['id'] = (dTags.first as List)[1]; if (isReplaceable) {
map['id'] = (
map['kind'] as int,
map['pubkey'].toString(),
tagMap['d']?.firstOrNull
).formatted;
} }
// Collect models for current kind and included for others // Collect models for current kind and included for others
@@ -58,9 +63,11 @@ mixin NostrAdapter<T extends DataModelMixin<T>> on Adapter<T> {
} }
final additionalKinds = params?.remove('kinds'); final additionalKinds = params?.remove('kinds');
final limit = params?.remove('limit');
final req = RelayRequest( final req = RelayRequest(
kinds: {kind, ...?additionalKinds}, kinds: {kind, ...?additionalKinds},
tags: params ?? {}, tags: params ?? {},
limit: limit,
); );
final result = await relay.queryRaw(req); final result = await relay.queryRaw(req);
+5 -9
View File
@@ -36,15 +36,11 @@ mixin ReleaseAdapter on Adapter<Release> {
@override @override
DeserializedData<Release> deserialize(Object? data, {String? key}) { DeserializedData<Release> deserialize(Object? data, {String? key}) {
final list = data is Iterable ? data : [data as Map]; final list = data is Iterable ? data : [data as Map];
for (final e in list) {
final map = e as Map<String, dynamic>; for (final Map<String, dynamic> map in list) {
final eTags = (map['tags'] as Iterable) final tagMap = tagsToMap(map['tags']);
.where((e) => e.first == 'e') map['app'] = tagMap['a']?.firstOrNull;
.map((e) => e[1].toString()); map['artifacts'] = tagMap['e']!.toList();
map['artifacts'] = eTags.toList();
final appIdentifier =
map['tags'].firstWhere((t) => t.first == 'd')[1].split('@').first;
map['app'] = appIdentifier;
} }
return super.deserialize(data); return super.deserialize(data);
} }
+1 -1
View File
@@ -32,6 +32,7 @@ class User extends BaseUser with DataModelMixin<User> {
} }
mixin UserAdapter on NostrAdapter<User> { mixin UserAdapter on NostrAdapter<User> {
// TODO: All this still necessary with purplebase?
@override @override
DeserializedData<User> deserialize(Object? data, {String? key}) { DeserializedData<User> deserialize(Object? data, {String? key}) {
final Iterable<Map<String, dynamic>> list = final Iterable<Map<String, dynamic>> list =
@@ -54,7 +55,6 @@ mixin UserAdapter on NostrAdapter<User> {
.toList() .toList()
.groupSetsBy((e) => e['pubkey'] as String); .groupSetsBy((e) => e['pubkey'] as String);
// TODO: This still necessary with purplebase?
// collect contacts and then assign them to user // collect contacts and then assign them to user
final included = <String, List<DataModelMixin>>{}; final included = <String, List<DataModelMixin>>{};
for (final _ in k3s.entries) { for (final _ in k3s.entries) {
+3 -2
View File
@@ -6,6 +6,7 @@ import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart'; import 'package:zapstore/main.dart';
import 'package:zapstore/main.data.dart'; import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/nostr_adapter.dart'; import 'package:zapstore/models/nostr_adapter.dart';
import 'package:zapstore/navigation/desktop_scaffold.dart'; import 'package:zapstore/navigation/desktop_scaffold.dart';
import 'package:zapstore/navigation/mobile_scaffold.dart'; import 'package:zapstore/navigation/mobile_scaffold.dart';
@@ -145,11 +146,11 @@ final dataLibrariesInitializer = FutureProvider<void>((ref) async {
await Future.wait([relay.initialize(), socialRelays.initialize()]); await Future.wait([relay.initialize(), socialRelays.initialize()]);
// Trigger app install status calculations // Trigger app install status calculations
await ref.apps.appAdapter.updateInstallStatus(); await ref.localApps.localAppAdapter.updateInstallStatus();
_lifecycleListener = AppLifecycleListener( _lifecycleListener = AppLifecycleListener(
onStateChange: (state) async { onStateChange: (state) async {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
await ref.apps.appAdapter.updateInstallStatus(); await ref.localApps.localAppAdapter.updateInstallStatus();
} }
}, },
); );
+1 -1
View File
@@ -134,7 +134,7 @@ class SearchResultNotifier extends AsyncNotifier<List<App>?> {
Future<List<App>?> build() async { Future<List<App>?> build() async {
final query = ref.watch(searchQueryProvider); final query = ref.watch(searchQueryProvider);
if (query != null) { if (query != null) {
return await ref.apps.findAll(params: {'search': query}); return await ref.apps.findAll(params: {'search': query, 'limit': 16});
} }
return null; return null;
} }
+6 -11
View File
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart';
import 'package:zapstore/main.data.dart'; import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart'; import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/local_app.dart';
import 'package:zapstore/models/settings.dart'; import 'package:zapstore/models/settings.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/system_info.dart'; import 'package:zapstore/utils/system_info.dart';
@@ -27,10 +28,10 @@ class InstallButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(installationProgressProvider(app.identifier!)); final progress = ref.watch(installationProgressProvider(app.identifier!));
final status = app.localApp.value?.status;
return GestureDetector( return GestureDetector(
onTap: switch (app.status) { onTap: switch (status) {
AppInstallStatus.differentArchitecture => null,
AppInstallStatus.downgrade => null, AppInstallStatus.downgrade => null,
AppInstallStatus.updated => () { AppInstallStatus.updated => () {
packageManager.openApp(app.id!.toString()); packageManager.openApp(app.id!.toString());
@@ -68,7 +69,7 @@ class InstallButton extends ConsumerWidget {
percent: switch (progress) { percent: switch (progress) {
VerifyingHashProgress() => 1, VerifyingHashProgress() => 1,
DownloadingInstallProgress(:final progress) => progress, DownloadingInstallProgress(:final progress) => progress,
_ => switch (app.status) { _ => switch (status) {
AppInstallStatus.updated => 1, AppInstallStatus.updated => 1,
_ => 0, _ => 0,
}, },
@@ -81,15 +82,9 @@ class InstallButton extends ConsumerWidget {
barRadius: Radius.circular(20), barRadius: Radius.circular(20),
padding: EdgeInsets.all(0), padding: EdgeInsets.all(0),
animateFromLastPercent: true, animateFromLastPercent: true,
center: switch (app.status) { center: switch (status) {
AppInstallStatus.loading =>
SizedBox(width: 14, height: 14, child: CircularProgressIndicator()),
AppInstallStatus.differentArchitecture => Text(
'Sorry, release does not support your device',
textAlign: TextAlign.center,
),
AppInstallStatus.downgrade => Text( AppInstallStatus.downgrade => Text(
'Installed version ${app.installedVersion ?? ''} is higher, can\'t downgrade', 'Installed version ${app.localApp.value?.installedVersion ?? ''} is higher, can\'t downgrade',
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
AppInstallStatus.updated => Text('Open'), AppInstallStatus.updated => Text('Open'),
+3 -8
View File
@@ -38,7 +38,7 @@ class LatestReleasesContainer extends HookConsumerWidget {
class LatestReleasesAppNotifier extends AutoDisposeAsyncNotifier<List<App>> { class LatestReleasesAppNotifier extends AutoDisposeAsyncNotifier<List<App>> {
@override @override
Future<List<App>> build() async { Future<List<App>> build() async {
final timer = Timer.periodic(Duration(hours: 4), (_) => fetch()); final timer = Timer.periodic(Duration(minutes: 10), (_) => fetch());
ref.onDispose(timer.cancel); ref.onDispose(timer.cancel);
fetch(); fetch();
// .catchError((e) { // .catchError((e) {
@@ -49,20 +49,15 @@ class LatestReleasesAppNotifier extends AutoDisposeAsyncNotifier<List<App>> {
} }
List<App> localFetch() { List<App> localFetch() {
return ref.releases return ref.apps
.findAllLocal() .findAllLocal()
.sorted((a, b) => b.createdAt!.compareTo(a.createdAt!)) .sorted((a, b) => b.createdAt!.compareTo(a.createdAt!))
.take(10) .take(10)
.map((r) => r.app.value)
.nonNulls
.toSet()
.toList(); .toList();
} }
Future<void> fetch() async { Future<void> fetch() async {
final releases = await ref.releases.findAll(params: {'limit': 10}); await ref.apps.findAll(params: {'limit': 10});
final appIds = releases.map((r) => r.app.id!.toString()).toSet();
await ref.apps.findAll(params: {'#d': appIds});
update((_) => localFetch()); update((_) => localFetch());
} }
} }
+3 -1
View File
@@ -24,7 +24,9 @@ class SignerAndDeveloperRow extends StatelessWidget {
launchUrl(url); launchUrl(url);
}, },
child: AuthorContainer( child: AuthorContainer(
user: app.developer.value!, text: 'Built by', oneLine: false), user: app.developer.value!,
text: 'Developed by',
oneLine: false),
), ),
if (app.signer.isPresent) if (app.signer.isPresent)
GestureDetector( GestureDetector(
+3 -1
View File
@@ -47,7 +47,9 @@ class VersionedAppHeader extends StatelessWidget {
children: [ children: [
if (isUpdate) if (isUpdate)
PillWidget( PillWidget(
text: app.installedVersion!, color: Colors.grey[800]!), text: app.localApp.value!.installedVersion!,
color: Colors.grey[800]!,
),
if (isUpdate) Icon(Icons.arrow_right), if (isUpdate) Icon(Icons.arrow_right),
if (app.latestMetadata != null) if (app.latestMetadata != null)
PillWidget( PillWidget(
+1 -1
View File
@@ -13,7 +13,7 @@ dependencies:
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
flutter_data: ^2.0.0-rc3 flutter_data: ^2.0.0-rc3
hooks_riverpod: ^2.4.9 hooks_riverpod: ^2.4.9
json_annotation: ^4.8.1 json_annotation: ^4.9.0
flutter_hooks: ^0.20.4 flutter_hooks: ^0.20.4
build_runner: ^2.4.8 build_runner: ^2.4.8
collection: ^1.18.0 collection: ^1.18.0