Compare commits

...

15 Commits

Author SHA1 Message Date
49b9a65053 Updated version 2022-09-30 15:37:32 -04:00
aebc8aed76 Clearer GitHub PAT instructions 2022-09-30 15:33:24 -04:00
3958425c22 Removed outdated comment 2022-09-29 23:28:49 -04:00
0a560871cb Fixed update checking on App page 2022-09-29 23:20:57 -04:00
fbe4f0b49e Added GitHub PAT support 2022-09-29 21:27:54 -04:00
e2440a38c4 App name now editable on App page 2022-09-29 16:45:24 -04:00
496a10a444 Added pull-to-refresh on App page when no webpage shown 2022-09-29 16:35:16 -04:00
b8bb8d1f4b Bugfix for F-Droid URL parsing 2022-09-29 10:15:57 -04:00
af033f42cb Updated modules 2022-09-28 22:43:24 -04:00
e706661062 Added URL selection menu for mass imports 2022-09-28 22:33:55 -04:00
1a68b8abe6 Improved GitHub starred import + other tweaks 2022-09-28 21:36:21 -04:00
15c0ed04d1 BG Updates *should* work now 2022-09-28 21:17:42 -04:00
dd193d62f2 Update checking improvements (#38)
Still no auto retry for rate-limit. Instead, rate-limit errors are ignored and the unchecked Apps have to wait until the next cycle. Even this needs more testing before release.
2022-09-27 23:20:39 -04:00
77e1768f3b Bugfix 2022-09-25 11:46:25 -04:00
da9e5aed5e Apps page UI improvements 2022-09-25 11:32:57 -04:00
21 changed files with 766 additions and 371 deletions

View File

@ -9,8 +9,14 @@ class FDroid implements AppSource {
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); RegExp standardUrlRegExB =
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
if (match != null) {
url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}';
}
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw notValidURL(runtimeType.toString()); throw notValidURL(runtimeType.toString());
} }
@ -54,4 +60,7 @@ class FDroid implements AppSource {
@override @override
List<String> additionalDataDefaults = []; List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -1,7 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
class GitHub implements AppSource { class GitHub implements AppSource {
@override @override
@ -17,6 +21,14 @@ class GitHub implements AppSource {
return url.substring(0, match.end); return url.substring(0, match.end);
} }
Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
String? creds =
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : '';
}
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
@ -28,7 +40,7 @@ class GitHub implements AppSource {
? additionalData[2] ? additionalData[2]
: null; : null;
Response res = await get(Uri.parse( Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); 'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>; var releases = jsonDecode(res.body) as List<dynamic>;
@ -76,7 +88,10 @@ class GitHub implements AppSource {
return APKDetails(version, targetRelease['apkUrls']); return APKDetails(version, targetRelease['apkUrls']);
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
} }
throw couldNotFindReleases; throw couldNotFindReleases;
@ -120,4 +135,43 @@ class GitHub implements AppSource {
@override @override
List<String> additionalDataDefaults = ['true', 'true', '']; List<String> additionalDataDefaults = ['true', 'true', ''];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [
GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds',
required: false,
additionalValidators: [
(value) {
if (value != null && value.trim().isNotEmpty) {
if (value
.split(':')
.where((element) => element.trim().isNotEmpty)
.length !=
2) {
return 'PAT must be in this format: username:token';
}
}
return null;
}
],
hint: 'username:token',
belowWidgets: [
const SizedBox(
height: 8,
),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication);
},
child: const Text(
'About GitHub PATs',
style: TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
))
])
];
} }

View File

@ -68,4 +68,7 @@ class GitLab implements AppSource {
@override @override
List<String> additionalDataDefaults = []; List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -62,4 +62,7 @@ class IzzyOnDroid implements AppSource {
@override @override
List<String> additionalDataDefaults = []; List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -48,4 +48,7 @@ class Mullvad implements AppSource {
@override @override
List<String> additionalDataDefaults = []; List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -41,4 +41,7 @@ class Signal implements AppSource {
@override @override
List<String> additionalDataDefaults = []; List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -65,4 +65,7 @@ class SourceForge implements AppSource {
@override @override
List<String> additionalDataDefaults = []; List<String> additionalDataDefaults = [];
@override
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
} }

View File

@ -10,13 +10,19 @@ class GeneratedFormItem {
late bool required; late bool required;
late int max; late int max;
late List<String? Function(String? value)> additionalValidators; late List<String? Function(String? value)> additionalValidators;
late String id;
late List<Widget> belowWidgets;
late String? hint;
GeneratedFormItem( GeneratedFormItem(
{this.label = 'Input', {this.label = 'Input',
this.type = FormItemType.string, this.type = FormItemType.string,
this.required = true, this.required = true,
this.max = 1, this.max = 1,
this.additionalValidators = const []}); this.additionalValidators = const [],
this.id = 'input',
this.belowWidgets = const [],
this.hint});
} }
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
@ -89,7 +95,8 @@ class _GeneratedFormState extends State<GeneratedForm> {
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? ' *' : '')), helperText: e.value.label + (e.value.required ? ' *' : ''),
hintText: e.value.hint),
minLines: e.value.max <= 1 ? null : e.value.max, minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max, maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) { validator: (value) {
@ -155,7 +162,13 @@ class _GeneratedFormState extends State<GeneratedForm> {
width: 20, width: 20,
)); ));
} }
rowItems.add(Expanded(child: rowInput.value)); rowItems.add(Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
}); });
rows.add(rowItems); rows.add(rowItems);
}); });

8
lib/custom_errors.dart Normal file
View File

@ -0,0 +1,8 @@
class RateLimitError {
late int remainingMinutes;
RateLimitError(this.remainingMinutes);
@override
String toString() =>
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/pages/home.dart'; import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart';
@ -13,22 +14,44 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag = const String currentReleaseTag =
'v0.4.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v0.5.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point') const String bgUpdateCheckTaskName = 'bg-update-check';
void bgTaskCallback() {
// Background update checking process bgUpdateCheck(int? ignoreAfterMicroseconds) async {
Workmanager().executeTask((task, taskName) async { DateTime? ignoreAfter = ignoreAfterMicroseconds != null
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
: null;
var notificationsProvider = NotificationsProvider(); var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification); await notificationsProvider.notify(checkingUpdatesNotification);
try { try {
var appsProvider = AppsProvider(); var appsProvider = AppsProvider();
await notificationsProvider await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps(); await appsProvider.loadApps();
// List<String> existingUpdateIds = // TODO: Uncomment this and below when it works // List<String> existingUpdateIds = // TODO: Uncomment this and below when it works
// appsProvider.getExistingUpdates(installedOnly: true); // appsProvider.getExistingUpdates(installedOnly: true);
List<App> newUpdates = await appsProvider.checkUpdates(); List<String> existingUpdateIds =
appsProvider.getExistingUpdates(installedOnly: true);
DateTime nextIgnoreAfter = DateTime.now();
try {
await appsProvider.checkUpdates(ignoreAfter: ignoreAfter);
} catch (e) {
if (e is RateLimitError) {
String nextTaskName =
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
constraints: Constraints(networkType: NetworkType.connected),
initialDelay: Duration(minutes: e.remainingMinutes),
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
} else {
rethrow;
}
}
List<App> newUpdates = appsProvider
.getExistingUpdates(installedOnly: true)
.where((id) => !existingUpdateIds.contains(id))
.map((e) => appsProvider.apps[e]!.app)
.toList();
// List<String> silentlyUpdated = await appsProvider // List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp( // .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null); // [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
@ -47,13 +70,19 @@ void bgTaskCallback() {
} }
return Future.value(true); return Future.value(true);
} catch (e) { } catch (e) {
notificationsProvider.notify( notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()),
ErrorCheckingUpdatesNotification(e.toString()),
cancelExisting: true); cancelExisting: true);
return Future.value(false); return Future.error(false);
} finally { } finally {
await notificationsProvider.cancel(checkingUpdatesNotification.id); await notificationsProvider.cancel(checkingUpdatesNotification.id);
} }
}
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background process callback
Workmanager().executeTask((task, inputData) async {
return await bgUpdateCheck(inputData?['ignoreAfter']);
}); });
} }
@ -95,16 +124,6 @@ class MyApp extends StatelessWidget {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} else { } else {
// Register the background update task according to the user's setting
if (settingsProvider.updateInterval > 0) {
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
} else {
Workmanager().cancelByUniqueName('bg-update-check');
}
bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) { if (isFirstRun) {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
@ -119,9 +138,24 @@ class MyApp extends StatelessWidget {
currentReleaseTag, currentReleaseTag,
[], [],
0, 0,
['true']) ['true'],
null)
]); ]);
} }
// Register the background update task according to the user's setting
if (settingsProvider.updateInterval == 0) {
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
} else {
Workmanager().registerPeriodicTask(
bgUpdateCheckTaskName, bgUpdateCheckTaskName,
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.keep,
backoffPolicy: BackoffPolicy.linear,
backoffPolicyDelay:
const Duration(minutes: minUpdateIntervalMinutes));
}
} }
return DynamicColorBuilder( return DynamicColorBuilder(

View File

@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppSource { class GitHubStars implements MassAppSource {
@ -10,23 +12,40 @@ class GitHubStars implements MassAppSource {
@override @override
late List<String> requiredArgs = ['Username']; late List<String> requiredArgs = ['Username'];
@override Future<List<String>> getOnePageOfUserStarredUrls(
Future<List<String>> getUrls(List<String> args) async { String username, int page) async {
if (args.length != requiredArgs.length) { Response res = await get(Uri.parse(
throw 'Wrong number of arguments provided'; 'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
}
Response res =
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>) return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String) .map((e) => e['html_url'] as String)
.toList(); .toList();
} else { } else {
if (res.headers['x-ratelimit-remaining'] == '0') { if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
60000000)
.round());
} }
throw 'Unable to find user\'s starred repos'; throw 'Unable to find user\'s starred repos';
} }
} }
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
}
List<String> urls = [];
var page = 1;
while (true) {
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++);
urls.addAll(pageUrls);
if (pageUrls.length < 100) {
break;
}
}
return urls;
}
} }

View File

@ -52,7 +52,7 @@ class _AddAppPageState extends State<AddAppPage> {
sourceProvider sourceProvider
.getSource(value ?? '') .getSource(value ?? '')
.standardizeURL( .standardizeURL(
makeUrlHttps( preStandardizeUrl(
value ?? '')); value ?? ''));
} catch (e) { } catch (e) {
return e is String return e is String

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -35,12 +36,16 @@ class _AppPageState extends State<AppPage> {
return Scaffold( return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null, appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: settingsProvider.showAppWebpage body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? WebView( ? WebView(
initialUrl: app?.app.url, initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted, javascriptMode: JavascriptMode.unrestricted,
) )
: Column( : CustomScrollView(
slivers: [
SliverFillRemaining(
child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -85,8 +90,30 @@ class _AppPageState extends State<AppPage> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
const SizedBox(
height: 32,
),
Text(
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
textAlign: TextAlign.center,
style: const TextStyle(
fontStyle: FontStyle.italic, fontSize: 12),
)
],
)),
], ],
), ),
onRefresh: () async {
if (app != null) {
try {
await appsProvider.getUpdate(app.app.id);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
}
}),
bottomSheet: Padding( bottomSheet: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom), 0, 0, 0, MediaQuery.of(context).padding.bottom),
@ -187,20 +214,35 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () {
showDialog( showDialog<List<String>>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Additional Options', title: 'Additional Options',
items: source items: [
...source
.additionalDataFormItems, .additionalDataFormItems,
[
GeneratedFormItem(
label: 'App Name',
required: true)
]
],
defaultValues: app != null defaultValues: app != null
? app.app.additionalData ? [
: source ...app
.additionalDataDefaults); .app.additionalData,
app.app.name
]
: [
...source
.additionalDataDefaults
]);
}).then((values) { }).then((values) {
if (app != null && values != null) { if (app != null && values != null) {
var changedApp = app.app; var changedApp = app.app;
var name = values.removeLast();
changedApp.name = name;
changedApp.additionalData = values; changedApp.additionalData = values;
appsProvider.saveApps([changedApp]); appsProvider.saveApps([changedApp]);
} }

View File

@ -18,6 +18,8 @@ class AppsPage extends StatefulWidget {
class AppsPageState extends State<AppsPage> { class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {}; Set<String> selectedIds = {};
clearSelected() { clearSelected() {
@ -45,6 +47,8 @@ class AppsPageState extends State<AppsPage> {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
var sortedApps = appsProvider.apps.values.toList(); var sortedApps = appsProvider.apps.values.toList();
var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds selectedIds = selectedIds
.where((element) => sortedApps.map((e) => e.app.id).contains(element)) .where((element) => sortedApps.map((e) => e.app.id).contains(element))
@ -112,6 +116,19 @@ class AppsPageState extends State<AppsPage> {
sortedApps = sortedApps.reversed.toList(); sortedApps = sortedApps.reversed.toList();
} }
var existingUpdateIdsAllOrSelected = appsProvider
.getExistingUpdates(installedOnly: true)
.where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
.toList();
var newInstallIdsAllOrSelected = appsProvider
.getExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
.toList();
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
@ -133,8 +150,9 @@ class AppsPageState extends State<AppsPage> {
: Text( : Text(
appsProvider.apps.isEmpty appsProvider.apps.isEmpty
? 'No Apps' ? 'No Apps'
: 'No Search Results', : 'No Apps for Filter',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
))), ))),
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
@ -175,26 +193,29 @@ class AppsPageState extends State<AppsPage> {
persistentFooterButtons: [ persistentFooterButtons: [
Row( Row(
children: [ children: [
TextButton.icon( IconButton(
onPressed: () { onPressed: () {
selectedIds.isEmpty selectedIds.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList()) ? selectThese(sortedApps.map((e) => e.app.id).toList())
: clearSelected(); : clearSelected();
}, },
icon: Icon(selectedIds.isEmpty icon: Icon(
selectedIds.isEmpty
? Icons.select_all_outlined ? Icons.select_all_outlined
: Icons.deselect_outlined), : Icons.deselect_outlined,
label: Text(selectedIds.isEmpty color: Theme.of(context).colorScheme.primary,
),
tooltip: selectedIds.isEmpty
? 'Select All' ? 'Select All'
: 'Deselect ${selectedIds.length.toString()}')), : 'Deselect ${selectedIds.length.toString()}'),
const VerticalDivider(), const VerticalDivider(),
Expanded( Expanded(
child: selectedIds.isEmpty child: Row(
? Container()
: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
IconButton( selectedIds.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: () { onPressed: () {
showDialog<List<String>?>( showDialog<List<String>?>(
@ -220,44 +241,24 @@ class AppsPageState extends State<AppsPage> {
IconButton( IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() || onPressed: appsProvider.areDownloadsRunning() ||
selectedIds (existingUpdateIdsAllOrSelected.isEmpty &&
.where((id) => newInstallIdsAllOrSelected.isEmpty)
appsProvider.apps[id]!.app
.installedVersion !=
appsProvider
.apps[id]!.app.latestVersion)
.isEmpty
? null ? null
: () { : () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
var existingUpdateIdsSelected = List<List<GeneratedFormItem>> formInputs = [];
appsProvider if (existingUpdateIdsAllOrSelected.isNotEmpty &&
.getExistingUpdates( newInstallIdsAllOrSelected.isNotEmpty) {
installedOnly: true)
.where((element) =>
selectedIds.contains(element))
.toList();
var newInstallIdsSelected = appsProvider
.getExistingUpdates(
nonInstalledOnly: true)
.where((element) =>
selectedIds.contains(element))
.toList();
List<List<GeneratedFormItem>> formInputs =
[];
if (existingUpdateIdsSelected
.isNotEmpty &&
newInstallIdsSelected.isNotEmpty) {
formInputs.add([ formInputs.add([
GeneratedFormItem( GeneratedFormItem(
label: label:
'Update ${existingUpdateIdsSelected.length} Apps?', 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool) type: FormItemType.bool)
]); ]);
formInputs.add([ formInputs.add([
GeneratedFormItem( GeneratedFormItem(
label: label:
'Install ${newInstallIdsSelected.length} new Apps?', 'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool) type: FormItemType.bool)
]); ]);
} }
@ -265,48 +266,46 @@ class AppsPageState extends State<AppsPage> {
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Install Selected Apps?', title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
message: message:
'${existingUpdateIdsSelected.length} update${existingUpdateIdsSelected.length == 1 ? '' : 's'} and ${newInstallIdsSelected.length} new install${newInstallIdsSelected.length == 1 ? '' : 's'}.', '${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
items: formInputs, items: formInputs,
defaultValues: const [ defaultValues: const ['true', 'true'],
'true',
'true'
],
initValid: true, initValid: true,
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
bool shouldInstallUpdates = bool shouldInstallUpdates =
values.length < 2 || values.length < 2 || values[0] == 'true';
values[0] == 'true';
bool shouldInstallNew = bool shouldInstallNew =
values.length < 2 || values.length < 2 || values[1] == 'true';
values[1] == 'true';
settingsProvider settingsProvider
.getInstallPermission() .getInstallPermission()
.then((_) { .then((_) {
List<String> toInstall = []; List<String> toInstall = [];
if (shouldInstallUpdates) { if (shouldInstallUpdates) {
toInstall.addAll( toInstall
existingUpdateIdsSelected); .addAll(existingUpdateIdsAllOrSelected);
} }
if (shouldInstallNew) { if (shouldInstallNew) {
toInstall.addAll( toInstall
newInstallIdsSelected); .addAll(newInstallIdsAllOrSelected);
} }
appsProvider appsProvider.downloadAndInstallLatestApp(
.downloadAndInstallLatestApp(
toInstall, context); toInstall, context);
}); });
} }
}); });
}, },
tooltip: 'Install/Update Selected Apps', tooltip:
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
icon: const Icon( icon: const Icon(
Icons.file_download_outlined, Icons.file_download_outlined,
)), )),
IconButton( selectedIds.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: () { onPressed: () {
String urls = ''; String urls = '';
@ -323,6 +322,27 @@ class AppsPageState extends State<AppsPage> {
], ],
)), )),
const VerticalDivider(), const VerticalDivider(),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() {
if (currentFilterIsUpdatesOnly) {
filter = null;
} else {
filter = updatesOnlyFilter;
}
});
},
tooltip: currentFilterIsUpdatesOnly
? 'Remove Out-of-Date App Filter'
: 'Show Out-of-Date Apps Only',
icon: Icon(
currentFilterIsUpdatesOnly
? Icons.update_disabled_rounded
: Icons.update_rounded,
color: Theme.of(context).colorScheme.primary,
),
),
appsProvider.apps.isEmpty appsProvider.apps.isEmpty
? const SizedBox() ? const SizedBox()
: TextButton.icon( : TextButton.icon(
@ -368,10 +388,6 @@ class AppsPageState extends State<AppsPage> {
filter = null; filter = null;
} }
}); });
} else {
setState(() {
filter = null;
});
} }
}); });
}, },

View File

@ -40,7 +40,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
Future<List<List<String>>> addApps(List<String> urls) async { Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission(); await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls); List<dynamic> results = await sourceProvider.getApps(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0]; List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1]; Map<String, dynamic> errorsMap = results[1];
for (var app in apps) { for (var app in apps) {
@ -266,30 +267,44 @@ class _ImportExportPageState extends State<ImportExportPage> {
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
source
.getUrls(values)
.then((urls) {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
addApps(urls) source
.then((errors) { .getUrls(values)
if (errors.isEmpty) { .then((urls) {
ScaffoldMessenger.of( showDialog<List<String>?>(
context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context, context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urls: urls);
})
.then((selectedUrls) {
if (selectedUrls !=
null) {
addApps(selectedUrls)
.then((errors) {
if (errors
.isEmpty) {
ScaffoldMessenger
.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${selectedUrls.length} Apps')),
);
} else {
showDialog(
context:
context,
builder: builder:
(BuildContext (BuildContext
ctx) { ctx) {
return ImportErrorDialog( return ImportErrorDialog(
urlsLength: urls urlsLength:
selectedUrls
.length, .length,
errors: errors:
errors); errors);
@ -301,7 +316,18 @@ class _ImportExportPageState extends State<ImportExportPage> {
false; false;
}); });
}); });
} else {
setState(() {
importInProgress =
false;
});
}
});
}).catchError((e) { }).catchError((e) {
setState(() {
importInProgress =
false;
});
ScaffoldMessenger.of( ScaffoldMessenger.of(
context) context)
.showSnackBar( .showSnackBar(
@ -375,3 +401,67 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
); );
} }
} }
// ignore: must_be_immutable
class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal({super.key, required this.urls});
List<String> urls;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
}
class _UrlSelectionModalState extends State<UrlSelectionModal> {
Map<String, bool> urlSelections = {};
@override
void initState() {
super.initState();
for (var url in widget.urls) {
urlSelections.putIfAbsent(url, () => true);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Select URLs to Import'),
content: Column(children: [
...urlSelections.keys.map((url) {
return Row(children: [
Checkbox(
value: urlSelections[url],
onChanged: (value) {
setState(() {
urlSelections[url] = value ?? false;
});
}),
const SizedBox(
width: 8,
),
Expanded(
child: Text(
Uri.parse(url).path.substring(1),
))
]);
})
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.of(context).pop(urlSelections.keys
.where((url) => urlSelections[url] ?? false)
.toList());
},
child: Text(
'Import ${urlSelections.values.where((b) => b).length} URLs'))
],
);
}
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -15,6 +17,7 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SettingsProvider settingsProvider = context.watch<SettingsProvider>(); SettingsProvider settingsProvider = context.watch<SettingsProvider>();
SourceProvider sourceProvider = SourceProvider();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
@ -22,8 +25,7 @@ class _SettingsPageState extends State<SettingsPage> {
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Settings'), const CustomAppBar(title: 'Settings'),
SliverFillRemaining( SliverToBoxAdapter(
hasScrollBody: true,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null child: settingsProvider.prefs == null
@ -160,7 +162,7 @@ class _SettingsPageState extends State<SettingsPage> {
height: 16, height: 16,
), ),
Text( Text(
'More', 'Updates',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.primary), color: Theme.of(context).colorScheme.primary),
), ),
@ -169,50 +171,89 @@ class _SettingsPageState extends State<SettingsPage> {
labelText: labelText:
'Background Update Checking Interval'), 'Background Update Checking Interval'),
value: settingsProvider.updateInterval, value: settingsProvider.updateInterval,
items: const [ items: updateIntervals.map((e) {
DropdownMenuItem( int displayNum = (e < 60
value: 15, ? e
child: Text('15 Minutes'), : e < 1440
), ? e / 60
DropdownMenuItem( : e / 1440)
value: 30, .round();
child: Text('30 Minutes'), var displayUnit = (e < 60
), ? 'Minute'
DropdownMenuItem( : e < 1440
value: 60, ? 'Hour'
child: Text('1 Hour'), : 'Day');
),
DropdownMenuItem( String display = e == 0
value: 360, ? 'Never - Manual Only'
child: Text('6 Hours'), : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
), return DropdownMenuItem(
DropdownMenuItem( value: e, child: Text(display));
value: 720, }).toList(),
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
DropdownMenuItem(
value: 0,
child: Text('Never - Manual Only'),
),
],
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsProvider.updateInterval = value; settingsProvider.updateInterval = value;
} }
}), }),
const Spacer(), const SizedBox(
Row( height: 8,
mainAxisAlignment: MainAxisAlignment.center, ),
Text(
'Longer intervals recommended for large App collections',
style: Theme.of(context)
.textTheme
.labelMedium!
.merge(const TextStyle(
fontStyle: FontStyle.italic)),
),
const Divider(
height: 48,
),
Text(
'Source-Specific',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
...sourceProvider.sources.map((e) {
if (e.moreSourceSettingsFormItems.isNotEmpty) {
return GeneratedForm(
items: e.moreSourceSettingsFormItems
.map((e) => [e])
.toList(),
onValueChanges: (values, valid) {
if (valid) {
for (var i = 0;
i < values.length;
i++) {
settingsProvider.setSettingString(
e.moreSourceSettingsFormItems[i]
.id,
values[i]);
}
}
},
defaultValues:
e.moreSourceSettingsFormItems.map((e) {
return settingsProvider
.getSettingString(e.id) ??
'';
}).toList());
} else {
return Container();
}
}),
],
))),
SliverToBoxAdapter(
child: Column(
children: [ children: [
const SizedBox(
height: 16,
),
TextButton.icon( TextButton.icon(
style: ButtonStyle( style: ButtonStyle(
foregroundColor: foregroundColor: MaterialStateProperty.resolveWith<Color>(
MaterialStateProperty.resolveWith< (Set<MaterialState> states) {
Color>((Set<MaterialState> states) {
return Colors.grey; return Colors.grey;
}), }),
), ),
@ -223,14 +264,15 @@ class _SettingsPageState extends State<SettingsPage> {
icon: const Icon(Icons.code), icon: const Icon(Icons.code),
label: Text( label: Text(
'Source', 'Source',
style: style: Theme.of(context).textTheme.bodySmall,
Theme.of(context).textTheme.bodySmall, ),
),
const SizedBox(
height: 16,
),
],
), ),
) )
],
),
],
)))
])); ]));
} }
} }

View File

@ -293,16 +293,32 @@ class AppsProvider with ChangeNotifier {
} }
await saveApps([newApp]); await saveApps([newApp]);
return newApp; return newApp;
} else if ((newApp.lastUpdateCheck?.microsecondsSinceEpoch ?? 0) -
(currentApp.lastUpdateCheck?.microsecondsSinceEpoch ?? 0) >
5000000) {
currentApp.lastUpdateCheck = newApp.lastUpdateCheck;
await saveApps([currentApp]);
} }
return null; return null;
} }
Future<List<App>> checkUpdates() async { Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async {
List<App> updates = []; List<App> updates = [];
if (!gettingUpdates) { if (!gettingUpdates) {
gettingUpdates = true; gettingUpdates = true;
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
if (ignoreAfter != null) {
appIds = appIds
.where((id) =>
apps[id]!.app.lastUpdateCheck == null ||
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
.toList();
}
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0))
.compareTo(apps[b]!.app.lastUpdateCheck ??
DateTime.fromMicrosecondsSinceEpoch(0)));
for (int i = 0; i < appIds.length; i++) { for (int i = 0; i < appIds.length; i++) {
App? newApp = await getUpdate(appIds[i]); App? newApp = await getUpdate(appIds[i]);
if (newApp != null) { if (newApp != null) {

View File

@ -13,6 +13,16 @@ enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending } enum SortOrderSettings { ascending, descending }
const maxAPIRateLimitMinutes = 30;
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
const maxUpdateIntervalMinutes = 4320;
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
.where((element) =>
(element >= minUpdateIntervalMinutes &&
element <= maxUpdateIntervalMinutes) ||
element == 0)
.toList();
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs; SharedPreferences? prefs;
@ -45,7 +55,17 @@ class SettingsProvider with ChangeNotifier {
} }
int get updateInterval { int get updateInterval {
return prefs?.getInt('updateInterval') ?? 1440; var min = prefs?.getInt('updateInterval') ?? 180;
if (!updateIntervals.contains(min)) {
var temp = updateIntervals[0];
for (var i in updateIntervals) {
if (min > i && i != 0) {
temp = i;
}
}
min = temp;
}
return min;
} }
set updateInterval(int min) { set updateInterval(int min) {
@ -95,11 +115,19 @@ class SettingsProvider with ChangeNotifier {
} }
bool get showAppWebpage { bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? true; return prefs?.getBool('showAppWebpage') ?? false;
} }
set showAppWebpage(bool show) { set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show); prefs?.setBool('showAppWebpage', show);
notifyListeners(); notifyListeners();
} }
String? getSettingString(String settingId) {
return prefs?.getString(settingId);
}
void setSettingString(String settingId, String value) {
prefs?.setString(settingId, value);
}
} }

View File

@ -38,6 +38,7 @@ class App {
List<String> apkUrls = []; List<String> apkUrls = [];
late int preferredApkIndex; late int preferredApkIndex;
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck;
App( App(
this.id, this.id,
this.url, this.url,
@ -47,7 +48,8 @@ class App {
this.latestVersion, this.latestVersion,
this.apkUrls, this.apkUrls,
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData); this.additionalData,
this.lastUpdateCheck);
@override @override
String toString() { String toString() {
@ -69,7 +71,10 @@ class App {
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults ? SourceProvider().getSource(json['url']).additionalDataDefaults
: List<String>.from(jsonDecode(json['additionalData']))); : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -80,7 +85,8 @@ class App {
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData) 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
}; };
} }
@ -90,7 +96,7 @@ escapeRegEx(String s) {
}); });
} }
makeUrlHttps(String url) { preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 && if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) { url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url'; url = 'https://$url';
@ -129,6 +135,7 @@ abstract class AppSource {
AppNames getAppNames(String standardUrl); AppNames getAppNames(String standardUrl);
late List<List<GeneratedFormItem>> additionalDataFormItems; late List<List<GeneratedFormItem>> additionalDataFormItems;
late List<String> additionalDataDefaults; late List<String> additionalDataDefaults;
late List<GeneratedFormItem> moreSourceSettingsFormItems;
} }
abstract class MassAppSource { abstract class MassAppSource {
@ -153,7 +160,7 @@ class SourceProvider {
List<MassAppSource> massSources = [GitHubStars()]; List<MassAppSource> massSources = [GitHubStars()];
AppSource getSource(String url) { AppSource getSource(String url) {
url = makeUrlHttps(url); url = preStandardizeUrl(url);
AppSource? source; AppSource? source;
for (var s in sources) { for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) { if (url.toLowerCase().contains('://${s.host}')) {
@ -180,7 +187,7 @@ class SourceProvider {
Future<App> getApp(AppSource source, String url, List<String> additionalData, Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String customName = ''}) async { {String customName = ''}) async {
String standardUrl = source.standardizeURL(makeUrlHttps(url)); String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl); AppNames names = source.getAppNames(standardUrl);
APKDetails apk = APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalData); await source.getLatestAPKDetails(standardUrl, additionalData);
@ -195,15 +202,17 @@ class SourceProvider {
apk.version, apk.version,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData); additionalData,
DateTime.now());
} }
/// Returns a length 2 list, where the first element is a list of Apps and /// Returns a length 2 list, where the first element is a list of Apps and
/// the second is a Map<String, dynamic> of URLs and errors /// the second is a Map<String, dynamic> of URLs and errors
Future<List<dynamic>> getApps(List<String> urls) async { Future<List<dynamic>> getApps(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
var source = getSource(url); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults)); apps.add(await getApp(source, url, source.additionalDataDefaults));

View File

@ -175,7 +175,7 @@ packages:
name: file_picker name: file_picker
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.2.0" version: "5.2.0+1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -386,7 +386,7 @@ packages:
name: path_provider_platform_interface name: path_provider_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.4" version: "2.0.5"
path_provider_windows: path_provider_windows:
dependency: transitive dependency: transitive
description: description:
@ -400,7 +400,7 @@ packages:
name: permission_handler name: permission_handler
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "10.0.1" version: "10.0.2"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
@ -421,7 +421,7 @@ packages:
name: permission_handler_platform_interface name: permission_handler_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.7.1" version: "3.8.0"
permission_handler_windows: permission_handler_windows:
dependency: transitive dependency: transitive
description: description:
@ -629,7 +629,7 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.5" version: "6.1.6"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
@ -664,7 +664,7 @@ packages:
name: url_launcher_platform_interface name: url_launcher_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.1.1"
url_launcher_web: url_launcher_web:
dependency: transitive dependency: transitive
description: description:
@ -699,21 +699,21 @@ packages:
name: webview_flutter_android name: webview_flutter_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.10.2" version: "2.10.3"
webview_flutter_platform_interface: webview_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.3" version: "1.9.5"
webview_flutter_wkwebview: webview_flutter_wkwebview:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_wkwebview name: webview_flutter_wkwebview
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.9.4" version: "2.9.5"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.4.0+19 # When changing this, update the tag in main() accordingly version: 0.5.2+23 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.19.0-79.0.dev <3.0.0' sdk: '>=2.19.0-79.0.dev <3.0.0'