diff --git a/.gitignore b/.gitignore index 6d8b901..15aa17f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .history .svn/ migrate_working_dir/ +.vscode/ # IntelliJ related *.iml diff --git a/lib/app_sources/fdroid.dart b/lib/app_sources/fdroid.dart index 2bdf958..7a2e62a 100644 --- a/lib/app_sources/fdroid.dart +++ b/lib/app_sources/fdroid.dart @@ -48,9 +48,6 @@ class FDroid extends AppSource { .where((element) => element['versionName'] == latestVersion) .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') .toList(); - if (apkUrls.isEmpty) { - throw NoAPKError(); - } return APKDetails(latestVersion, apkUrls); } else { throw NoReleasesError(); diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index 058f550..a0347c2 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -11,9 +11,9 @@ class GitHub extends AppSource { GitHub() { host = 'github.com'; - additionalDataDefaults = ['true', 'true', '']; + additionalSourceAppSpecificDefaults = ['true', 'true', '']; - moreSourceSettingsFormItems = [ + additionalSourceSpecificSettingFormItems = [ GeneratedFormItem( label: 'GitHub Personal Access Token (Increases Rate Limit)', id: 'github-creds', @@ -51,7 +51,7 @@ class GitHub extends AppSource { ]) ]; - additionalDataFormItems = [ + additionalSourceAppSpecificFormItems = [ [ GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool) ], @@ -96,8 +96,8 @@ class GitHub extends AppSource { Future getCredentialPrefixIfAny() async { SettingsProvider settingsProvider = SettingsProvider(); await settingsProvider.initializeSettings(); - String? creds = - settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); + String? creds = settingsProvider + .getSettingString(additionalSourceSpecificSettingFormItems[0].id); return creds != null && creds.isNotEmpty ? '$creds@' : ''; } @@ -155,14 +155,11 @@ class GitHub extends AppSource { if (targetRelease == null) { throw NoReleasesError(); } - if ((targetRelease['apkUrls'] as List).isEmpty) { - throw NoAPKError(); - } String? version = targetRelease['tag_name']; if (version == null) { throw NoVersionError(); } - return APKDetails(version, targetRelease['apkUrls']); + return APKDetails(version, targetRelease['apkUrls'] as List); } else { rateLimitErrorCheck(res); throw getObtainiumHttpError(res); diff --git a/lib/app_sources/gitlab.dart b/lib/app_sources/gitlab.dart index 546dd03..62bee02 100644 --- a/lib/app_sources/gitlab.dart +++ b/lib/app_sources/gitlab.dart @@ -33,7 +33,7 @@ class GitLab extends AppSource { var entry = parsedHtml.querySelector('entry'); var entryContent = parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); - var apkUrlList = [ + var apkUrls = [ ...getLinksFromParsedHTML( entryContent, RegExp( @@ -48,9 +48,6 @@ class GitLab extends AppSource { .where((element) => Uri.parse(element).host != '') .toList() ]; - if (apkUrlList.isEmpty) { - throw NoAPKError(); - } var entryId = entry?.querySelector('id')?.innerHtml; var version = @@ -58,7 +55,7 @@ class GitLab extends AppSource { if (version == null) { throw NoVersionError(); } - return APKDetails(version, apkUrlList); + return APKDetails(version, apkUrls); } else { throw NoReleasesError(); } diff --git a/lib/app_sources/signal.dart b/lib/app_sources/signal.dart index 4d38130..94cfdad 100644 --- a/lib/app_sources/signal.dart +++ b/lib/app_sources/signal.dart @@ -24,14 +24,12 @@ class Signal extends AppSource { if (res.statusCode == 200) { var json = jsonDecode(res.body); String? apkUrl = json['url']; - if (apkUrl == null) { - throw NoAPKError(); - } + List apkUrls = apkUrl == null ? [] : [apkUrl]; String? version = json['versionName']; if (version == null) { throw NoVersionError(); } - return APKDetails(version, [apkUrl]); + return APKDetails(version, apkUrls); } else { throw NoReleasesError(); } diff --git a/lib/app_sources/sourceforge.dart b/lib/app_sources/sourceforge.dart index 6d6e815..f3d5e1e 100644 --- a/lib/app_sources/sourceforge.dart +++ b/lib/app_sources/sourceforge.dart @@ -49,9 +49,6 @@ class SourceForge extends AppSource { apkUrlListAllReleases // This can be used skipped for fallback support later .where((element) => getVersion(element) == version) .toList(); - if (apkUrlList.isEmpty) { - throw NoAPKError(); - } return APKDetails(version, apkUrlList); } else { throw NoReleasesError(); diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart index 51418c9..df252ab 100644 --- a/lib/components/generated_form.dart +++ b/lib/components/generated_form.dart @@ -6,6 +6,7 @@ typedef OnValueChanges = void Function( List values, bool valid, bool isBuilding); class GeneratedFormItem { + late String key; late String label; late FormItemType type; late bool required; @@ -25,7 +26,8 @@ class GeneratedFormItem { this.id = 'input', this.belowWidgets = const [], this.hint, - this.opts}); + this.opts, + this.key = 'default'}); } class GeneratedForm extends StatefulWidget { @@ -209,3 +211,18 @@ class _GeneratedFormState extends State { )); } } + +String? findGeneratedFormValueByKey( + List items, List values, String key) { + var foundIndex = -1; + for (var i = 0; i < items.length; i++) { + if (items[i].key == key) { + foundIndex = i; + break; + } + } + if (foundIndex >= 0 && foundIndex < values.length) { + return values[foundIndex]; + } + return null; +} diff --git a/lib/components/generated_form_modal.dart b/lib/components/generated_form_modal.dart index 7406b51..faea40c 100644 --- a/lib/components/generated_form_modal.dart +++ b/lib/components/generated_form_modal.dart @@ -29,7 +29,7 @@ class _GeneratedFormModalState extends State { void initState() { super.initState(); values = widget.defaultValues; - valid = widget.initValid; + valid = widget.initValid || widget.items.isEmpty; } @override diff --git a/lib/main.dart b/lib/main.dart index 3a5075e..3dad04a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -154,6 +154,7 @@ class _ObtainiumState extends State { 0, ['true'], null, + false, false) ]); } diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index 5c06917..9750c99 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/pages/app.dart'; import 'package:obtainium/providers/apps_provider.dart'; @@ -22,8 +23,10 @@ class _AddAppPageState extends State { String userInput = ''; AppSource? pickedSource; - List additionalData = []; - bool validAdditionalData = true; + List sourceSpecificAdditionalData = []; + bool sourceSpecificDataIsValid = true; + List otherAdditionalData = []; + bool otherAdditionalDataIsValid = true; @override Widget build(BuildContext context) { @@ -67,23 +70,34 @@ class _AddAppPageState extends State { ] ], onValueChanges: (values, valid, isBuilding) { - setState(() { + fn() { userInput = values[0]; var source = valid ? sourceProvider.getSource(userInput) : null; if (pickedSource != source) { pickedSource = source; - additionalData = source != null - ? source.additionalDataDefaults + sourceSpecificAdditionalData = source != + null + ? source + .additionalSourceAppSpecificDefaults : []; - validAdditionalData = source != null + sourceSpecificDataIsValid = source != + null ? sourceProvider .ifSourceAppsRequireAdditionalData( source) : true; } - }); + } + + if (isBuilding) { + fn(); + } else { + setState(() { + fn(); + }); + } }, defaultValues: const [])), const SizedBox( @@ -94,9 +108,14 @@ class _AddAppPageState extends State { : ElevatedButton( onPressed: gettingAppInfo || pickedSource == null || - (pickedSource!.additionalDataFormItems + (pickedSource! + .additionalSourceAppSpecificFormItems .isNotEmpty && - !validAdditionalData) + !sourceSpecificDataIsValid) || + (pickedSource! + .additionalAppSpecificSourceAgnosticDefaults + .isNotEmpty && + !otherAdditionalDataIsValid) ? null : () async { setState(() { @@ -107,47 +126,87 @@ class _AddAppPageState extends State { var settingsProvider = context.read(); () async { - HapticFeedback.selectionClick(); - App app = - await sourceProvider.getApp( - pickedSource!, - userInput, - additionalData); - await settingsProvider - .getInstallPermission(); - // Only download the APK here if you need to for the package ID - if (sourceProvider - .isTempId(app.id)) { - // ignore: use_build_context_synchronously - var apkUrl = await appsProvider - .confirmApkUrl(app, context); - if (apkUrl == null) { - throw ObtainiumError( - 'Cancelled'); + var userPickedTrackOnly = + findGeneratedFormValueByKey( + pickedSource! + .additionalAppSpecificSourceAgnosticFormItems, + otherAdditionalData, + 'trackOnlyFormItemKey') == + 'true'; + var cont = true; + if ((userPickedTrackOnly || + pickedSource! + .enforceTrackOnly) && + await showDialog( + context: context, + builder: + (BuildContext ctx) { + return GeneratedFormModal( + title: + 'App is Track-Only', + items: const [], + defaultValues: const [], + message: + '${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.', + ); + }) == + null) { + cont = false; + } + if (cont) { + HapticFeedback.selectionClick(); + App app = await sourceProvider.getApp( + pickedSource!, + userInput, + sourceSpecificAdditionalData, + trackOnly: pickedSource! + .enforceTrackOnly || + userPickedTrackOnly); + await settingsProvider + .getInstallPermission(); + // Only download the APK here if you need to for the package ID + if (sourceProvider + .isTempId(app.id) && + !app.trackOnly) { + // ignore: use_build_context_synchronously + var apkUrl = await appsProvider + .confirmApkUrl( + app, context); + if (apkUrl == null) { + throw ObtainiumError( + 'Cancelled'); + } + app.preferredApkIndex = + app.apkUrls.indexOf(apkUrl); + var downloadedApk = + await appsProvider + .downloadApp(app); + app.id = downloadedApk.appId; } - app.preferredApkIndex = - app.apkUrls.indexOf(apkUrl); - var downloadedApk = - await appsProvider - .downloadApp(app); - app.id = downloadedApk.appId; - } - if (appsProvider.apps - .containsKey(app.id)) { - throw ObtainiumError( - 'App already added'); - } - await appsProvider.saveApps([app]); + if (appsProvider.apps + .containsKey(app.id)) { + throw ObtainiumError( + 'App already added'); + } + if (app.trackOnly) { + app.installedVersion = + app.latestVersion; + } + await appsProvider + .saveApps([app]); - return app; + return app; + } }() .then((app) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - AppPage( - appId: app.id))); + if (app != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AppPage( + appId: app.id))); + } }).catchError((e) { showError(e, context); }).whenComplete(() { @@ -160,7 +219,11 @@ class _AddAppPageState extends State { ], ), if (pickedSource != null && - pickedSource!.additionalDataDefaults.isNotEmpty) + (pickedSource!.additionalSourceAppSpecificDefaults + .isNotEmpty || + pickedSource! + .additionalAppSpecificSourceAgnosticDefaults + .isNotEmpty)) Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -176,19 +239,54 @@ class _AddAppPageState extends State { height: 16, ), if (pickedSource! - .additionalDataFormItems.isNotEmpty) + .additionalSourceAppSpecificFormItems + .isNotEmpty) GeneratedForm( - items: pickedSource!.additionalDataFormItems, + items: pickedSource! + .additionalSourceAppSpecificFormItems, onValueChanges: (values, valid, isBuilding) { - setState(() { - additionalData = values; - validAdditionalData = valid; - }); + if (isBuilding) { + sourceSpecificAdditionalData = values; + sourceSpecificDataIsValid = valid; + } else { + setState(() { + sourceSpecificAdditionalData = values; + sourceSpecificDataIsValid = valid; + }); + } }, - defaultValues: - pickedSource!.additionalDataDefaults), + defaultValues: pickedSource! + .additionalSourceAppSpecificDefaults), if (pickedSource! - .additionalDataFormItems.isNotEmpty) + .additionalSourceAppSpecificFormItems + .isNotEmpty) + const SizedBox( + height: 8, + ), + if (pickedSource! + .additionalAppSpecificSourceAgnosticFormItems + .isNotEmpty) + GeneratedForm( + items: pickedSource! + .additionalAppSpecificSourceAgnosticFormItems + .map((e) => [e]) + .toList(), + onValueChanges: (values, valid, isBuilding) { + if (isBuilding) { + otherAdditionalData = values; + otherAdditionalDataIsValid = valid; + } else { + setState(() { + otherAdditionalData = values; + otherAdditionalDataIsValid = valid; + }); + } + }, + defaultValues: pickedSource! + .additionalAppSpecificSourceAgnosticDefaults), + if (pickedSource! + .additionalAppSpecificSourceAgnosticDefaults + .isNotEmpty) const SizedBox( height: 8, ), diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 19fd3bb..81f7667 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -106,7 +106,7 @@ class _AppPageState extends State { style: Theme.of(context).textTheme.bodyLarge, ), Text( - 'Installed Version: ${app?.app.installedVersion ?? 'None'}', + 'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), @@ -140,6 +140,7 @@ class _AppPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ if (app?.app.installedVersion != null && + app?.app.trackOnly == false && app?.app.installedVersion != app?.app.latestVersion) IconButton( onPressed: app?.downloadProgress != null @@ -183,7 +184,8 @@ class _AppPageState extends State { tooltip: 'Mark as Updated', icon: const Icon(Icons.done)), if (source != null && - source.additionalDataFormItems.isNotEmpty) + source.additionalSourceAppSpecificFormItems + .isNotEmpty) IconButton( onPressed: app?.downloadProgress != null ? null @@ -194,11 +196,11 @@ class _AppPageState extends State { return GeneratedFormModal( title: 'Additional Options', items: source - .additionalDataFormItems, + .additionalSourceAppSpecificFormItems, defaultValues: app != null ? app.app.additionalData : source - .additionalDataDefaults); + .additionalSourceAppSpecificDefaults); }).then((values) { if (app != null && values != null) { var changedApp = app.app; @@ -234,8 +236,12 @@ class _AppPageState extends State { } : null, child: Text(app?.app.installedVersion == null - ? 'Install' - : 'Update'))), + ? app?.app.trackOnly == false + ? 'Install' + : 'Mark Installed' + : app?.app.trackOnly == false + ? 'Update' + : 'Mark Updated'))), const SizedBox(width: 16.0), ElevatedButton( onPressed: app?.downloadProgress != null diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 6a029e8..d10422f 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -135,6 +135,22 @@ class AppsPageState extends State { : selectedApps.map((e) => e.id).contains(element)) .toList(); + List trackOnlyUpdateIdsAllOrSelected = []; + existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) { + if (appsProvider.apps[id]!.app.trackOnly) { + trackOnlyUpdateIdsAllOrSelected.add(id); + return false; + } + return true; + }).toList(); + newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) { + if (appsProvider.apps[id]!.app.trackOnly) { + trackOnlyUpdateIdsAllOrSelected.add(id); + return false; + } + return true; + }).toList(); + if (settingsProvider.pinUpdates) { var temp = []; sortedApps = sortedApps.where((sa) { @@ -245,7 +261,7 @@ class AppsPageState extends State { children: [ Text(appsProvider.areDownloadsRunning() ? 'Please Wait...' - : 'Update Available'), + : 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'), SourceProvider() .getSource(sortedApps[index].app.url) .changeLogPageFromStandardUrl( @@ -276,8 +292,7 @@ class AppsPageState extends State { child: SizedBox( width: 80, child: Text( - sortedApps[index].app.installedVersion ?? - 'Not Installed', + '${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}', overflow: TextOverflow.fade, textAlign: TextAlign.end, )))), @@ -349,50 +364,70 @@ class AppsPageState extends State { visualDensity: VisualDensity.compact, onPressed: appsProvider.areDownloadsRunning() || (existingUpdateIdsAllOrSelected.isEmpty && - newInstallIdsAllOrSelected.isEmpty) + newInstallIdsAllOrSelected.isEmpty && + trackOnlyUpdateIdsAllOrSelected.isEmpty) ? null : () { HapticFeedback.heavyImpact(); - List> formInputs = []; - if (existingUpdateIdsAllOrSelected.isNotEmpty && - newInstallIdsAllOrSelected.isNotEmpty) { - formInputs.add([ - GeneratedFormItem( - label: - 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', - type: FormItemType.bool) - ]); - formInputs.add([ - GeneratedFormItem( - label: - 'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', - type: FormItemType.bool) - ]); + List formInputs = []; + List defaultValues = []; + if (existingUpdateIdsAllOrSelected.isNotEmpty) { + formInputs.add(GeneratedFormItem( + label: + 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', + type: FormItemType.bool, + key: 'updates')); + defaultValues.add('true'); + } + if (newInstallIdsAllOrSelected.isNotEmpty) { + formInputs.add(GeneratedFormItem( + label: + 'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', + type: FormItemType.bool, + key: 'installs')); + defaultValues + .add(defaultValues.isEmpty ? 'true' : ''); + } + if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { + formInputs.add(GeneratedFormItem( + label: + 'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated', + type: FormItemType.bool, + key: 'trackonlies')); + defaultValues + .add(defaultValues.isEmpty ? 'true' : ''); } showDialog?>( context: context, builder: (BuildContext ctx) { + var totalApps = existingUpdateIdsAllOrSelected + .length + + newInstallIdsAllOrSelected.length + + trackOnlyUpdateIdsAllOrSelected.length; return GeneratedFormModal( title: - 'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?', - message: - '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', - items: formInputs, - defaultValues: [ - 'true', - existingUpdateIdsAllOrSelected.isEmpty - ? 'true' - : '' - ], + 'Change $totalApps App${totalApps == 1 ? '' : 's'}', + items: formInputs.map((e) => [e]).toList(), + defaultValues: defaultValues, initValid: true, ); }).then((values) { if (values != null) { if (values.isEmpty) { - values = ['true', 'true']; + values = defaultValues; } - bool shouldInstallUpdates = values[0] == 'true'; - bool shouldInstallNew = values[1] == 'true'; + bool shouldInstallUpdates = + findGeneratedFormValueByKey( + formInputs, values, 'updates') == + 'true'; + bool shouldInstallNew = + findGeneratedFormValueByKey( + formInputs, values, 'installs') == + 'true'; + bool shouldMarkTrackOnlies = + findGeneratedFormValueByKey(formInputs, + values, 'trackonlies') == + 'true'; settingsProvider .getInstallPermission() .then((_) { @@ -405,6 +440,10 @@ class AppsPageState extends State { toInstall .addAll(newInstallIdsAllOrSelected); } + if (shouldMarkTrackOnlies) { + toInstall.addAll( + trackOnlyUpdateIdsAllOrSelected); + } appsProvider .downloadAndInstallLatestApps( toInstall, context) diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index 1d61f48..906496e 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -42,7 +42,7 @@ class _ImportExportPageState extends State { Future>> addApps(List urls) async { await settingsProvider.getInstallPermission(); - List results = await sourceProvider.getApps(urls, + List results = await sourceProvider.getAppsByURLNaive(urls, ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList()); List apps = results[0]; Map errorsMap = results[1]; diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 25a0c97..f4cb9d0 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -139,18 +139,21 @@ class _SettingsPageState extends State { }); var sourceSpecificFields = sourceProvider.sources.map((e) { - if (e.moreSourceSettingsFormItems.isNotEmpty) { + if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) { return GeneratedForm( - items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), + items: e.additionalSourceSpecificSettingFormItems + .map((e) => [e]) + .toList(), onValueChanges: (values, valid, isBuilding) { if (valid) { for (var i = 0; i < values.length; i++) { settingsProvider.setSettingString( - e.moreSourceSettingsFormItems[i].id, values[i]); + e.additionalSourceSpecificSettingFormItems[i].id, + values[i]); } } }, - defaultValues: e.moreSourceSettingsFormItems.map((e) { + defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) { return settingsProvider.getSettingString(e.id) ?? ''; }).toList()); } else { diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index ba150f3..578d828 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -266,6 +266,7 @@ class AppsProvider with ChangeNotifier { Future> downloadAndInstallLatestApps( List appIds, BuildContext? context) async { List appsToInstall = []; + List trackOnlyAppsToUpdate = []; // For all specified Apps, filter out those for which: // 1. A URL cannot be picked // 2. That cannot be installed silently (IF no buildContext was given for interactive install) @@ -273,7 +274,10 @@ class AppsProvider with ChangeNotifier { if (apps[id] == null) { throw ObtainiumError('App not found'); } - String? apkUrl = await confirmApkUrl(apps[id]!.app, context); + String? apkUrl; + if (!apps[id]!.app.trackOnly) { + apkUrl = await confirmApkUrl(apps[id]!.app, context); + } if (apkUrl != null) { int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); if (urlInd != apps[id]!.app.preferredApkIndex) { @@ -284,7 +288,16 @@ class AppsProvider with ChangeNotifier { appsToInstall.add(id); } } + if (apps[id]!.app.trackOnly) { + trackOnlyAppsToUpdate.add(id); + } } + // Mark all specified track-only apps as latest + saveApps(trackOnlyAppsToUpdate.map((e) { + var a = apps[e]!.app; + a.installedVersion = a.latestVersion; + return a; + }).toList()); // Download APKs for all Apps to be installed MultiAppMultiError errors = MultiAppMultiError(); List downloadedFiles = @@ -391,7 +404,9 @@ class AppsProvider with ChangeNotifier { return null; // Can't correct in the background isolate } var modded = false; - if (installedInfo == null && app.installedVersion != null) { + if (installedInfo == null && + app.installedVersion != null && + !app.trackOnly) { app.installedVersion = null; modded = true; } @@ -445,8 +460,7 @@ class AppsProvider with ChangeNotifier { var info = await getInstalledInfo(newApps[i].id); try { sp.getSource(newApps[i].url); - apps.putIfAbsent( - newApps[i].id, () => AppInMemory(newApps[i], null, info)); + apps[newApps[i].id] = AppInMemory(newApps[i], null, info); } catch (e) { errors.add([newApps[i].id, newApps[i].name, e.toString()]); } @@ -512,8 +526,9 @@ class AppsProvider with ChangeNotifier { currentApp.additionalData, name: currentApp.name, id: currentApp.id, - pinned: currentApp.pinned); - newApp.installedVersion = currentApp.installedVersion; + pinned: currentApp.pinned, + trackOnly: currentApp.trackOnly, + installedVersion: currentApp.installedVersion); if (currentApp.preferredApkIndex < newApp.apkUrls.length) { newApp.preferredApkIndex = currentApp.preferredApkIndex; } diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 7641c68..5e9e693 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -42,6 +42,7 @@ class App { late List additionalData; late DateTime? lastUpdateCheck; bool pinned = false; + bool trackOnly = false; App( this.id, this.url, @@ -53,7 +54,8 @@ class App { this.preferredApkIndex, this.additionalData, this.lastUpdateCheck, - this.pinned); + this.pinned, + this.trackOnly); @override String toString() { @@ -74,12 +76,15 @@ class App { : List.from(jsonDecode(json['apkUrls'])), json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['additionalData'] == null - ? SourceProvider().getSource(json['url']).additionalDataDefaults + ? SourceProvider() + .getSource(json['url']) + .additionalSourceAppSpecificDefaults : List.from(jsonDecode(json['additionalData'])), json['lastUpdateCheck'] == null ? null : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), - json['pinned'] ?? false); + json['pinned'] ?? false, + json['trackOnly'] ?? false); Map toJson() => { 'id': id, @@ -92,7 +97,8 @@ class App { 'preferredApkIndex': preferredApkIndex, 'additionalData': jsonEncode(additionalData), 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, - 'pinned': pinned + 'pinned': pinned, + 'trackOnly': trackOnly }; } @@ -135,6 +141,7 @@ List getLinksFromParsedHTML( class AppSource { late String host; + bool enforceTrackOnly = false; String standardizeURL(String url) { throw NotImplementedError(); } @@ -148,9 +155,22 @@ class AppSource { throw NotImplementedError(); } - List> additionalDataFormItems = []; - List additionalDataDefaults = []; - List moreSourceSettingsFormItems = []; + // Different Sources may need different kinds of additional data for Apps + List> additionalSourceAppSpecificFormItems = []; + List additionalSourceAppSpecificDefaults = []; + + // Some additional data may be needed for Apps regardless of Source + final List additionalAppSpecificSourceAgnosticFormItems = [ + GeneratedFormItem( + label: 'Track-Only', + type: FormItemType.bool, + key: 'trackOnlyFormItemKey') + ]; + final List additionalAppSpecificSourceAgnosticDefaults = ['']; + + // Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider + List additionalSourceSpecificSettingFormItems = []; + String? changeLogPageFromStandardUrl(String standardUrl) { throw NotImplementedError(); } @@ -211,7 +231,7 @@ class SourceProvider { } bool ifSourceAppsRequireAdditionalData(AppSource source) { - for (var row in source.additionalDataFormItems) { + for (var row in source.additionalSourceAppSpecificFormItems) { for (var element in row) { if (element.required) { return true; @@ -238,11 +258,19 @@ class SourceProvider { } Future getApp(AppSource source, String url, List additionalData, - {String name = '', String? id, bool pinned = false}) async { + {String name = '', + String? id, + bool pinned = false, + bool trackOnly = false, + String? installedVersion}) async { String standardUrl = source.standardizeURL(preStandardizeUrl(url)); AppNames names = source.getAppNames(standardUrl); APKDetails apk = await source.getLatestAPKDetails(standardUrl, additionalData); + if (apk.apkUrls.isEmpty && !trackOnly) { + throw NoAPKError(); + } + String apkVersion = apk.version.replaceAll('/', '-'); return App( id ?? source.tryInferringAppId(standardUrl) ?? @@ -252,24 +280,26 @@ class SourceProvider { name.trim().isNotEmpty ? name : names.name[0].toUpperCase() + names.name.substring(1), - null, - apk.version.replaceAll('/', '-'), + installedVersion, + apkVersion, apk.apkUrls, apk.apkUrls.length - 1, additionalData, DateTime.now(), - pinned); + pinned, + trackOnly); } // Returns errors in [results, errors] instead of throwing them - Future> getApps(List urls, + Future> getAppsByURLNaive(List urls, {List ignoreUrls = const []}) async { List apps = []; Map errors = {}; for (var url in urls.where((element) => !ignoreUrls.contains(element))) { try { var source = getSource(url); - apps.add(await getApp(source, url, source.additionalDataDefaults)); + apps.add(await getApp( + source, url, source.additionalSourceAppSpecificDefaults)); } catch (e) { errors.addAll({url: e}); }