diff --git a/android/app/build.gradle b/android/app/build.gradle index 2559d75..77c8d88 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 34 + compileSdkVersion rootProject.ext.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { @@ -54,7 +54,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. minSdkVersion 24 - targetSdkVersion 34 + targetSdkVersion rootProject.ext.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 503cd67..3e13a38 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ regExValidator(value)]), + ], + [ + GeneratedFormTextField('matchGroupToUse', + label: tr('matchGroupToUse'), + required: false, + hint: '0', + textInputType: const TextInputType.numberWithOptions(), + additionalValidators: [ + (value) { + if (value?.isEmpty == true) { + value = null; + } + value ??= '0'; + return intValidator(value); + } + ]) + ], + [ + GeneratedFormSwitch('versionExtractWholePage', + label: tr('versionExtractWholePage')) + ], + [ + GeneratedFormSwitch('supportFixedAPKURL', + defaultValue: true, label: tr('supportFixedAPKURL')), + ], + ]; + var commonFormItems = [ + [GeneratedFormSwitch('filterByLinkText', label: tr('filterByLinkText'))], + [GeneratedFormSwitch('skipSort', label: tr('skipSort'))], + [GeneratedFormSwitch('reverseSort', label: tr('takeFirstLink'))], + [ + GeneratedFormSwitch('sortByLastLinkSegment', + label: tr('sortByLastLinkSegment')) + ], + ]; + var intermediateFormItems = [ + [ + GeneratedFormTextField('customLinkFilterRegex', + label: tr('intermediateLinkRegex'), + hint: '([0-9]+.)*[0-9]+/\$', + required: true, + additionalValidators: [(value) => regExValidator(value)]) + ], + ]; HTML() { additionalSourceAppSpecificSettingFormItems = [ [ - GeneratedFormSwitch('sortByFileNamesNotLinks', - label: tr('sortByFileNamesNotLinks')) + GeneratedFormSubForm( + 'intermediateLink', [...intermediateFormItems, ...commonFormItems], + label: tr('intermediateLink')) ], - [GeneratedFormSwitch('skipSort', label: tr('skipSort'))], - [GeneratedFormSwitch('reverseSort', label: tr('takeFirstLink'))], - [ - GeneratedFormSwitch('supportFixedAPKURL', - defaultValue: true, label: tr('supportFixedAPKURL')), - ], - [ - GeneratedFormTextField('customLinkFilterRegex', - label: tr('customLinkFilterRegex'), - hint: 'download/(.*/)?(android|apk|mobile)', - required: false, - additionalValidators: [ - (value) { - return regExValidator(value); - } - ]) - ], - [ - GeneratedFormTextField('intermediateLinkRegex', - label: tr('intermediateLinkRegex'), - hint: '([0-9]+.)*[0-9]+/\$', - required: false, - additionalValidators: [(value) => regExValidator(value)]) - ], - [ - GeneratedFormTextField('versionExtractionRegEx', - label: tr('versionExtractionRegEx'), - required: false, - additionalValidators: [(value) => regExValidator(value)]), - ], - [ - GeneratedFormTextField('matchGroupToUse', - label: tr('matchGroupToUse'), - required: false, - hint: '0', - textInputType: const TextInputType.numberWithOptions(), - additionalValidators: [ - (value) { - if (value?.isEmpty == true) { - value = null; - } - value ??= '0'; - return intValidator(value); - } - ]) - ], - [ - GeneratedFormSwitch('versionExtractWholePage', - label: tr('versionExtractWholePage')) - ] + finalStepFormitems[0], + ...commonFormItems, + ...finalStepFormitems.sublist(1) ]; overrideVersionDetectionFormDefault('noVersionDetection', disableStandard: false, disableRelDate: true); @@ -164,107 +179,120 @@ class HTML extends AppSource { return url; } + // Given an HTTP response, grab some links according to the common additional settings + // (those that apply to intermediate and final steps) + Future>> grabLinksCommon( + Response res, Map additionalSettings) async { + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var html = parse(res.body); + List> allLinks = html + .querySelectorAll('a') + .map((element) => MapEntry( + element.attributes['href'] ?? '', + element.text.isNotEmpty + ? element.text + : (element.attributes['href'] ?? '').split('/').last)) + .where((element) => element.key.isNotEmpty) + .toList(); + if (allLinks.isEmpty) { + allLinks = RegExp( + r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?') + .allMatches(res.body) + .map((match) => + MapEntry(match.group(0)!, match.group(0)?.split('/').last ?? '')) + .toList(); + } + List> links = []; + bool skipSort = additionalSettings['skipSort'] == true; + bool filterLinkByText = additionalSettings['filterByLinkText'] == true; + if ((additionalSettings['customLinkFilterRegex'] as String?)?.isNotEmpty == + true) { + var reg = RegExp(additionalSettings['customLinkFilterRegex']); + links = allLinks + .where((element) => + reg.hasMatch(filterLinkByText ? element.value : element.key)) + .toList(); + } else { + links = allLinks + .where((element) => + Uri.parse(filterLinkByText ? element.value : element.key) + .path + .toLowerCase() + .endsWith('.apk')) + .toList(); + } + if (!skipSort) { + links.sort((a, b) => additionalSettings['sortByLastLinkSegment'] == true + ? compareAlphaNumeric( + a.key.split('/').where((e) => e.isNotEmpty).last, + b.key.split('/').where((e) => e.isNotEmpty).last) + : compareAlphaNumeric(a.key, b.key)); + } + if (additionalSettings['reverseSort'] == true) { + links = links.reversed.toList(); + } + return links; + } + @override Future getLatestAPKDetails( String standardUrl, Map additionalSettings, ) async { - var uri = Uri.parse(standardUrl); - Response res = await sourceRequest(standardUrl); - if (res.statusCode == 200) { - var html = parse(res.body); - List allLinks = html - .querySelectorAll('a') - .map((element) => element.attributes['href'] ?? '') - .where((element) => element.isNotEmpty) - .toList(); - if (allLinks.isEmpty) { - allLinks = RegExp( - r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?') - .allMatches(res.body) - .map((match) => match.group(0)!) - .toList(); - } - List links = []; - bool skipSort = additionalSettings['skipSort'] == true; - if ((additionalSettings['intermediateLinkRegex'] as String?) - ?.isNotEmpty == - true) { - var reg = RegExp(additionalSettings['intermediateLinkRegex']); - links = allLinks.where((element) => reg.hasMatch(element)).toList(); - if (!skipSort) { - links.sort((a, b) => compareAlphaNumeric(a, b)); - } - if (links.isEmpty) { - throw ObtainiumError(tr('intermediateLinkNotFound')); - } - Map additionalSettingsTemp = - Map.from(additionalSettings); - additionalSettingsTemp['intermediateLinkRegex'] = null; - return getLatestAPKDetails( - ensureAbsoluteUrl(links.last, uri), additionalSettingsTemp); - } - if ((additionalSettings['customLinkFilterRegex'] as String?) - ?.isNotEmpty == - true) { - var reg = RegExp(additionalSettings['customLinkFilterRegex']); - links = allLinks.where((element) => reg.hasMatch(element)).toList(); - } else { - links = allLinks - .where((element) => - Uri.parse(element).path.toLowerCase().endsWith('.apk')) - .toList(); - } - if (!skipSort) { - links.sort((a, b) => - additionalSettings['sortByFileNamesNotLinks'] == true - ? compareAlphaNumeric( - a.split('/').where((e) => e.isNotEmpty).last, - b.split('/').where((e) => e.isNotEmpty).last) - : compareAlphaNumeric(a, b)); - } - if (additionalSettings['reverseSort'] == true) { - links = links.reversed.toList(); - } - if ((additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == - true) { - var reg = RegExp(additionalSettings['apkFilterRegEx']); - links = links.where((element) => reg.hasMatch(element)).toList(); - } - if (links.isEmpty) { + var currentUrl = standardUrl; + for (int i = 0; + i < (additionalSettings['intermediateLink']?.length ?? 0); + i++) { + var intLinks = await grabLinksCommon(await sourceRequest(currentUrl), + additionalSettings['intermediateLink'][i]); + if (intLinks.isEmpty) { throw NoReleasesError(); + } else { + currentUrl = intLinks.last.key; } - var rel = links.last; - String? version; - if (additionalSettings['supportFixedAPKURL'] != true) { - version = rel.hashCode.toString(); - } - var versionExtractionRegEx = - additionalSettings['versionExtractionRegEx'] as String?; - if (versionExtractionRegEx?.isNotEmpty == true) { - var match = RegExp(versionExtractionRegEx!).allMatches( - additionalSettings['versionExtractWholePage'] == true - ? res.body.split('\r\n').join('\n').split('\n').join('\\n') - : rel); - if (match.isEmpty) { - throw NoVersionError(); - } - String matchGroupString = - (additionalSettings['matchGroupToUse'] as String).trim(); - if (matchGroupString.isEmpty) { - matchGroupString = "0"; - } - version = match.last.group(int.parse(matchGroupString)); - if (version?.isEmpty == true) { - throw NoVersionError(); - } - } - rel = ensureAbsoluteUrl(rel, uri); - version ??= (await checkDownloadHash(rel)).toString(); - return APKDetails(version, [rel].map((e) => MapEntry(e, e)).toList(), - AppNames(uri.host, tr('app'))); - } else { - throw getObtainiumHttpError(res); } + + var uri = Uri.parse(currentUrl); + Response res = await sourceRequest(currentUrl); + var links = await grabLinksCommon(res, additionalSettings); + + if ((additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == true) { + var reg = RegExp(additionalSettings['apkFilterRegEx']); + links = links.where((element) => reg.hasMatch(element.key)).toList(); + } + if (links.isEmpty) { + throw NoReleasesError(); + } + var rel = links.last.key; + String? version; + if (additionalSettings['supportFixedAPKURL'] != true) { + version = rel.hashCode.toString(); + } + var versionExtractionRegEx = + additionalSettings['versionExtractionRegEx'] as String?; + if (versionExtractionRegEx?.isNotEmpty == true) { + var match = RegExp(versionExtractionRegEx!).allMatches( + additionalSettings['versionExtractWholePage'] == true + ? res.body.split('\r\n').join('\n').split('\n').join('\\n') + : rel); + if (match.isEmpty) { + throw NoVersionError(); + } + String matchGroupString = + (additionalSettings['matchGroupToUse'] as String).trim(); + if (matchGroupString.isEmpty) { + matchGroupString = "0"; + } + version = match.last.group(int.parse(matchGroupString)); + if (version?.isEmpty == true) { + throw NoVersionError(); + } + } + rel = ensureAbsoluteUrl(rel, uri); + version ??= (await checkDownloadHash(rel)).toString(); + return APKDetails(version, [rel].map((e) => MapEntry(e, e)).toList(), + AppNames(uri.host, tr('app'))); } } diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart index af9a929..0958cc5 100644 --- a/lib/components/generated_form.dart +++ b/lib/components/generated_form.dart @@ -4,6 +4,7 @@ import 'package:hsluv/hsluv.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/components/generated_form_modal.dart'; +import 'package:obtainium/providers/source_provider.dart'; abstract class GeneratedFormItem { late String key; @@ -31,7 +32,8 @@ class GeneratedFormTextField extends GeneratedFormItem { {super.label, super.belowWidgets, String super.defaultValue = '', - List super.additionalValidators = const [], + List super.additionalValidators = + const [], this.required = true, this.max = 1, this.hint, @@ -117,6 +119,18 @@ class GeneratedForm extends StatefulWidget { State createState() => _GeneratedFormState(); } +class GeneratedFormSubForm extends GeneratedFormItem { + final List> items; + + GeneratedFormSubForm(super.key, this.items, + {super.label, super.belowWidgets, super.defaultValue}); + + @override + ensureType(val) { + return val; // Not easy to validate List> + } +} + // Generates a color in the HSLuv (Pastel) color space // https://pub.dev/documentation/hsluv/latest/hsluv/Hsluv/hpluvToRgb.html Color generateRandomLightColor() { @@ -133,6 +147,9 @@ Color generateRandomLightColor() { return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]); } +bool validateTextField(TextFormField tf) => + (tf.key as GlobalKey).currentState?.isValid == true; + class _GeneratedFormState extends State { final _formKey = GlobalKey(); Map values = {}; @@ -141,20 +158,19 @@ class _GeneratedFormState extends State { String? initKey; // If any value changes, call this to update the parent with value and validity - void someValueChanged({bool isBuilding = false}) { + void someValueChanged({bool isBuilding = false, bool forceInvalid = false}) { Map returnValues = values; var valid = true; for (int r = 0; r < widget.items.length; r++) { for (int i = 0; i < widget.items[r].length; i++) { if (formInputs[r][i] is TextFormField) { - var fieldState = - (formInputs[r][i].key as GlobalKey).currentState; - if (fieldState != null) { - valid = valid && fieldState.isValid; - } + valid = valid && validateTextField(formInputs[r][i] as TextFormField); } } } + if (forceInvalid) { + valid = false; + } widget.onValueChanges(returnValues, valid, isBuilding); } @@ -229,6 +245,17 @@ class _GeneratedFormState extends State { someValueChanged(); }); }); + } else if (formItem is GeneratedFormSubForm) { + values[formItem.key] = []; + for (Map v + in ((formItem.defaultValue ?? []) as List)) { + var fullDefaults = getDefaultValuesFromFormItems(formItem.items); + for (var element in v.entries) { + fullDefaults[element.key] = element.value; + } + values[formItem.key].add(fullDefaults); + } + return Container(); } else { return Container(); // Some input types added in build } @@ -250,6 +277,7 @@ class _GeneratedFormState extends State { } for (var r = 0; r < formInputs.length; r++) { for (var e = 0; e < formInputs[r].length; e++) { + String fieldKey = widget.items[r][e].key; if (widget.items[r][e] is GeneratedFormSwitch) { formInputs[r][e] = Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -259,10 +287,10 @@ class _GeneratedFormState extends State { width: 8, ), Switch( - value: values[widget.items[r][e].key], + value: values[fieldKey], onChanged: (value) { setState(() { - values[widget.items[r][e].key] = value; + values[fieldKey] = value; someValueChanged(); }); }) @@ -271,8 +299,7 @@ class _GeneratedFormState extends State { } else if (widget.items[r][e] is GeneratedFormTagInput) { formInputs[r][e] = Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if ((values[widget.items[r][e].key] - as Map>?) + if ((values[fieldKey] as Map>?) ?.isNotEmpty == true && (widget.items[r][e] as GeneratedFormTagInput) @@ -295,8 +322,7 @@ class _GeneratedFormState extends State { (widget.items[r][e] as GeneratedFormTagInput).alignment, crossAxisAlignment: WrapCrossAlignment.center, children: [ - (values[widget.items[r][e].key] - as Map>?) + (values[fieldKey] as Map>?) ?.isEmpty == true ? Text( @@ -304,8 +330,7 @@ class _GeneratedFormState extends State { .emptyMessage, ) : const SizedBox.shrink(), - ...(values[widget.items[r][e].key] - as Map>?) + ...(values[fieldKey] as Map>?) ?.entries .map((e2) { return Padding( @@ -318,11 +343,10 @@ class _GeneratedFormState extends State { selected: e2.value.value, onSelected: (value) { setState(() { - (values[widget.items[r][e].key] as Map>)[e2.key] = MapEntry( - (values[widget.items[r][e].key] as Map< - String, + (values[fieldKey] as Map>)[e2.key]! .key, value); @@ -330,22 +354,18 @@ class _GeneratedFormState extends State { as GeneratedFormTagInput) .singleSelect && value == true) { - for (var key in (values[ - widget.items[r][e].key] + for (var key in (values[fieldKey] as Map>) .keys) { if (key != e2.key) { - (values[widget.items[r][e].key] as Map< - String, - MapEntry>)[key] = - MapEntry( - (values[widget.items[r][e].key] - as Map< - String, - MapEntry>)[key]! - .key, - false); + (values[fieldKey] as Map< + String, + MapEntry>)[key] = MapEntry( + (values[fieldKey] as Map>)[key]! + .key, + false); } } } @@ -355,8 +375,7 @@ class _GeneratedFormState extends State { )); }) ?? [const SizedBox.shrink()], - (values[widget.items[r][e].key] - as Map>?) + (values[fieldKey] as Map>?) ?.values .where((e) => e.value) .length == @@ -366,7 +385,7 @@ class _GeneratedFormState extends State { child: IconButton( onPressed: () { setState(() { - var temp = values[widget.items[r][e].key] + var temp = values[fieldKey] as Map>; // get selected category str where bool is true final oldEntry = temp.entries @@ -379,7 +398,7 @@ class _GeneratedFormState extends State { // Update entry with new color, remain selected temp.update(oldEntry.key, (old) => MapEntry(newColor, old.value)); - values[widget.items[r][e].key] = temp; + values[fieldKey] = temp; someValueChanged(); }); }, @@ -388,8 +407,7 @@ class _GeneratedFormState extends State { tooltip: tr('colour'), )) : const SizedBox.shrink(), - (values[widget.items[r][e].key] - as Map>?) + (values[fieldKey] as Map>?) ?.values .where((e) => e.value) .isNotEmpty == @@ -400,10 +418,10 @@ class _GeneratedFormState extends State { onPressed: () { fn() { setState(() { - var temp = values[widget.items[r][e].key] + var temp = values[fieldKey] as Map>; temp.removeWhere((key, value) => value.value); - values[widget.items[r][e].key] = temp; + values[fieldKey] = temp; someValueChanged(); }); } @@ -454,7 +472,7 @@ class _GeneratedFormState extends State { String? label = value?['label']; if (label != null) { setState(() { - var temp = values[widget.items[r][e].key] + var temp = values[fieldKey] as Map>?; temp ??= {}; if (temp[label] == null) { @@ -467,7 +485,7 @@ class _GeneratedFormState extends State { temp[label] = MapEntry( generateRandomLightColor().value, !(someSelected && singleSelect)); - values[widget.items[r][e].key] = temp; + values[fieldKey] = temp; someValueChanged(); } }); @@ -481,6 +499,85 @@ class _GeneratedFormState extends State { ], ) ]); + } else if (widget.items[r][e] is GeneratedFormSubForm) { + List subformColumn = []; + for (int i = 0; i < values[fieldKey].length; i++) { + subformColumn.add(Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const SizedBox( + height: 16, + ), + Text( + '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + GeneratedForm( + items: (widget.items[r][e] as GeneratedFormSubForm) + .items + .map((x) => x.map((y) { + y.defaultValue = values[fieldKey]?[i]?[y.key]; + return y; + }).toList()) + .toList(), + onValueChanges: (values, valid, isBuilding) { + if (valid) { + this.values[fieldKey]?[i] = values; + } + someValueChanged( + isBuilding: isBuilding, forceInvalid: !valid); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.error), + onPressed: (values[fieldKey].length > 0) + ? () { + var temp = List.from(values[fieldKey]); + temp.removeAt(i); + values[fieldKey] = List.from(temp); + someValueChanged(); + } + : null, + label: Text( + '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', + ), + icon: const Icon( + Icons.delete_outline_rounded, + )) + ], + ), + ], + )); + } + subformColumn.add(Padding( + padding: EdgeInsets.only( + bottom: values[fieldKey].length > 0 ? 24 : 0, top: 8), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + values[fieldKey].add(getDefaultValuesFromFormItems( + (widget.items[r][e] as GeneratedFormSubForm) + .items)); + someValueChanged(); + }, + icon: const Icon(Icons.add), + label: Text((widget.items[r][e] as GeneratedFormSubForm) + .label))), + ], + ), + )); + if (values[fieldKey].length > 0) { + subformColumn.add(const Divider()); + } + formInputs[r][e] = Column(children: subformColumn); } } } diff --git a/lib/main.dart b/lib/main.dart index 21be97b..de57a6c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,14 +12,14 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; +import 'package:background_fetch/background_fetch.dart'; import 'package:easy_localization/easy_localization.dart'; // ignore: implementation_imports import 'package:easy_localization/src/easy_localization_controller.dart'; // ignore: implementation_imports import 'package:easy_localization/src/localization.dart'; -const String currentVersion = '0.14.41'; +const String currentVersion = '0.15.0'; const String currentReleaseTag = 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES @@ -76,6 +76,19 @@ Future loadTranslations() async { fallbackTranslations: controller.fallbackTranslations); } +@pragma('vm:entry-point') +void backgroundFetchHeadlessTask(HeadlessTask task) async { + String taskId = task.taskId; + bool isTimeout = task.timeout; + if (isTimeout) { + print('BG update task timed out.'); + BackgroundFetch.finish(taskId); + return; + } + await bgUpdateCheck(taskId, null); + BackgroundFetch.finish(taskId); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); try { @@ -93,7 +106,6 @@ void main() async { ); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); } - await AndroidAlarmManager.initialize(); runApp(MultiProvider( providers: [ ChangeNotifierProvider(create: (context) => AppsProvider()), @@ -108,6 +120,7 @@ void main() async { useOnlyLangCode: true, child: const Obtainium()), )); + BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); } var defaultThemeColour = Colors.deepPurple; @@ -122,6 +135,33 @@ class Obtainium extends StatefulWidget { class _ObtainiumState extends State { var existingUpdateInterval = -1; + @override + void initState() { + super.initState(); + initPlatformState(); + } + + Future initPlatformState() async { + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + stopOnTerminate: false, + enableHeadless: true, + requiresBatteryNotLow: false, + requiresCharging: false, + requiresStorageNotLow: false, + requiresDeviceIdle: false, + requiredNetworkType: NetworkType.ANY), (String taskId) async { + // We don't want periodic tasks in the foreground - ignore + await bgUpdateCheck(taskId, null); + BackgroundFetch.finish(taskId); + }, (String taskId) async { + context.read().add('BG update task timed out.'); + BackgroundFetch.finish(taskId); + }); + if (!mounted) return; + } + @override Widget build(BuildContext context) { SettingsProvider settingsProvider = context.watch(); @@ -161,30 +201,6 @@ class _ObtainiumState extends State { context.locale.languageCode)) { settingsProvider.resetLocaleSafe(context); } - // Register the background update task according to the user's setting - var actualUpdateInterval = settingsProvider.updateInterval; - if (existingUpdateInterval != actualUpdateInterval) { - if (actualUpdateInterval == 0) { - AndroidAlarmManager.cancel(bgUpdateCheckAlarmId); - } else { - var settingChanged = existingUpdateInterval != -1; - var lastCheckWasTooLongAgo = actualUpdateInterval != 0 && - settingsProvider.lastBGCheckTime - .add(Duration(minutes: actualUpdateInterval + 60)) - .isBefore(DateTime.now()); - if (settingChanged || lastCheckWasTooLongAgo) { - logs.add( - 'Update interval was set to ${actualUpdateInterval.toString()} (reason: ${settingChanged ? 'setting changed' : 'last check was ${settingsProvider.lastBGCheckTime.toLocal().toString()}'}).'); - AndroidAlarmManager.periodic( - Duration(minutes: actualUpdateInterval), - bgUpdateCheckAlarmId, - bgUpdateCheck, - rescheduleOnReboot: true, - wakeup: true); - } - } - existingUpdateInterval = actualUpdateInterval; - } } return DynamicColorBuilder( diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index e83ddf3..d889de4 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -1,4 +1,3 @@ -import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -608,38 +607,35 @@ class _SettingsPageState extends State { const Divider( height: 32, ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column(children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible(child: Text(tr('debugMenu'))), - Switch( - value: settingsProvider.showDebugOpts, - onChanged: (value) { - settingsProvider.showDebugOpts = value; - }) - ], - ), - if (settingsProvider.showDebugOpts) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - height16, - TextButton( - onPressed: () { - AndroidAlarmManager.oneShot( - const Duration(seconds: 0), - bgUpdateCheckAlarmId + 200, - bgUpdateCheck); - showMessage(tr('bgTaskStarted'), context); - }, - child: Text(tr('runBgCheckNow'))) - ], - ), - ]), - ), + // Padding( + // padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + // child: Column(children: [ + // Row( + // mainAxisAlignment: MainAxisAlignment.spaceBetween, + // children: [ + // Flexible(child: Text(tr('debugMenu'))), + // Switch( + // value: settingsProvider.showDebugOpts, + // onChanged: (value) { + // settingsProvider.showDebugOpts = value; + // }) + // ], + // ), + // if (settingsProvider.showDebugOpts) + // Column( + // crossAxisAlignment: CrossAxisAlignment.stretch, + // children: [ + // height16, + // TextButton( + // onPressed: () { + // bgUpdateCheck('taskId', null); + // showMessage(tr('bgTaskStarted'), context); + // }, + // child: Text(tr('runBgCheckNow'))) + // ], + // ), + // ]), + // ), ], ), ) diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 97e6118..99503f2 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -8,7 +8,6 @@ import 'dart:math'; import 'package:http/http.dart' as http; import 'package:crypto/crypto.dart'; -import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; import 'package:android_intent_plus/flag.dart'; import 'package:android_package_installer/android_package_installer.dart'; import 'package:android_package_manager/android_package_manager.dart'; @@ -621,7 +620,8 @@ class AppsProvider with ChangeNotifier { // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result Future> downloadAndInstallLatestApps( List appIds, BuildContext? context, - {NotificationsProvider? notificationsProvider}) async { + {NotificationsProvider? notificationsProvider, + bool forceParallelDownloads = false}) async { notificationsProvider = notificationsProvider ?? context?.read(); List appsToInstall = []; @@ -742,7 +742,7 @@ class AppsProvider with ChangeNotifier { } } - if (!settingsProvider.parallelDownloads) { + if (forceParallelDownloads || !settingsProvider.parallelDownloads) { for (var id in appsToInstall) { await updateFn(id); } @@ -1448,19 +1448,17 @@ class _APKOriginWarningDialogState extends State { /// When toCheck is empty, the function is in "install mode" (else it is in "update mode"). /// In update mode, all apps in toCheck are checked for updates (in parallel). /// If an update is available and it cannot be installed silently, the user is notified of the available update. -/// If there are any errors, the task is run again for the remaining apps after a few minutes (based on the error with the longest retry interval). -/// Any app that has reached it's retry limit, the user is notified that it could not be checked. +/// If there are any errors, we recursively call the same function with retry count for the relevant apps decremented (if zero, the user is notified). /// /// Once all update checks are complete, the task is run again in install mode. -/// In this mode, all pending silent updates are downloaded and installed in the background (serially - one at a time). -/// If there is an error, the offending app is moved to the back of the line of remaining apps, and the task is retried. -/// If an app repeatedly fails to install up to its retry limit, the user is notified. +/// In this mode, all pending silent updates are downloaded (in parallel) and installed in the background. +/// If there is an error, the user is notified. /// -@pragma('vm:entry-point') -Future bgUpdateCheck(int taskId, Map? params) async { +Future bgUpdateCheck(String taskId, Map? params) async { + // ignore: avoid_print + print('Started $taskId: ${params.toString()}'); WidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized(); - await AndroidAlarmManager.initialize(); await loadTranslations(); LogsProvider logs = LogsProvider(); @@ -1469,11 +1467,20 @@ Future bgUpdateCheck(int taskId, Map? params) async { await appsProvider.loadApps(); int maxAttempts = 4; + int maxRetryWaitSeconds = 5; + + var netResult = await (Connectivity().checkConnectivity()); + if (netResult == ConnectivityResult.none) { + logs.add('BG update task: No network.'); + return; + } params ??= {}; - if (params['toCheck'] == null) { - appsProvider.settingsProvider.lastBGCheckTime = DateTime.now(); - } + + bool firstEverUpdateTask = DateTime.fromMillisecondsSinceEpoch(0) + .compareTo(appsProvider.settingsProvider.lastCompletedBGCheckTime) == + 0; + List> toCheck = >[ ...(params['toCheck'] ?.map((entry) => MapEntry( @@ -1481,6 +1488,11 @@ Future bgUpdateCheck(int taskId, Map? params) async { .toList() ?? appsProvider .getAppsSortedByUpdateCheckTime( + ignoreAppsCheckedAfter: params['toCheck'] == null + ? firstEverUpdateTask + ? null + : appsProvider.settingsProvider.lastCompletedBGCheckTime + : null, onlyCheckInstalledOrTrackOnlyApps: appsProvider .settingsProvider.onlyCheckInstalledOrTrackOnlyApps) .map((e) => MapEntry(e, 0))) @@ -1493,51 +1505,34 @@ Future bgUpdateCheck(int taskId, Map? params) async { (>>[])) ]; - var netResult = await (Connectivity().checkConnectivity()); - - if (netResult == ConnectivityResult.none) { - var networkBasedRetryInterval = 15; - var nextRegularCheck = appsProvider.settingsProvider.lastBGCheckTime - .add(Duration(minutes: appsProvider.settingsProvider.updateInterval)); - var potentialNetworkRetryCheck = - DateTime.now().add(Duration(minutes: networkBasedRetryInterval)); - var shouldRetry = potentialNetworkRetryCheck.isBefore(nextRegularCheck); - logs.add( - 'BG update task $taskId: No network. Will ${shouldRetry ? 'retry in $networkBasedRetryInterval minutes' : 'not retry'}.'); - AndroidAlarmManager.oneShot( - const Duration(minutes: 15), taskId + 1, bgUpdateCheck, - params: { - 'toCheck': toCheck - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList(), - 'toInstall': toInstall - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList(), - }); - return; - } - var networkRestricted = false; if (appsProvider.settingsProvider.bgUpdatesOnWiFiOnly) { networkRestricted = (netResult != ConnectivityResult.wifi) && (netResult != ConnectivityResult.ethernet); } - bool installMode = - toCheck.isEmpty; // Task is either in update mode or install mode - - logs.add( - 'BG ${installMode ? 'install' : 'update'} task $taskId: Started (${installMode ? toInstall.length : toCheck.length}).'); - - if (!installMode) { + if (toCheck.isNotEmpty) { + // Task is either in update mode or install mode // If in update mode, we check for updates. // We divide the results into 4 groups: // - toNotify - Apps with updates that the user will be notified about (can't be silently installed) - // - toRetry - Apps with update check errors that will be retried in a while // - toThrow - Apps with update check errors that the user will be notified about (no retry) // After grouping the updates, we take care of toNotify and toThrow first - // Then if toRetry is not empty, we schedule another update task to run in a while - // If toRetry is empty, we take care of schedule another task that will run in install mode (toCheck is empty) + // Then we run the function again in install mode (toCheck is empty) + + var enoughTimePassed = appsProvider.settingsProvider.updateInterval != 0 && + appsProvider.settingsProvider.lastCompletedBGCheckTime + .add( + Duration(minutes: appsProvider.settingsProvider.updateInterval)) + .isBefore(DateTime.now()); + if (!enoughTimePassed) { + // ignore: avoid_print + print( + 'BG update task: Too early for another check (last check was ${appsProvider.settingsProvider.lastCompletedBGCheckTime.toIso8601String()}, interval is ${appsProvider.settingsProvider.updateInterval}).'); + return; + } + + logs.add('BG update task: Started (${toCheck.length}).'); // Init. vars. List updates = []; // All updates found (silent and non-silent) @@ -1545,8 +1540,7 @@ Future bgUpdateCheck(int taskId, Map? params) async { []; // All non-silent updates that the user will be notified about List> toRetry = []; // All apps that got errors while checking - var retryAfterXSeconds = - 0; // How long to wait until the next attempt (if there are errors) + var retryAfterXSeconds = 0; MultiAppMultiError? errors; // All errors including those that will lead to a retry MultiAppMultiError toThrow = @@ -1569,27 +1563,32 @@ Future bgUpdateCheck(int taskId, Map? params) async { specificIds: toCheck.map((e) => e.key).toList(), sp: appsProvider.settingsProvider); } catch (e) { - // If there were errors, group them into toRetry and toThrow based on max retry count per app if (e is Map) { updates = e['updates']; errors = e['errors']; errors!.rawErrors.forEach((key, err) { logs.add( - 'BG update task $taskId: Got error on checking for $key \'${err.toString()}\'.'); + 'BG update task: Got error on checking for $key \'${err.toString()}\'.'); + var toCheckApp = toCheck.where((element) => element.key == key).first; if (toCheckApp.value < maxAttempts) { toRetry.add(MapEntry(toCheckApp.key, toCheckApp.value + 1)); // Next task interval is based on the error with the longest retry time - var minRetryIntervalForThisApp = err is RateLimitError + int minRetryIntervalForThisApp = err is RateLimitError ? (err.remainingMinutes * 60) : e is ClientException ? (15 * 60) - : pow(toCheckApp.value + 1, 2).toInt(); + : (toCheckApp.value + 1); + if (minRetryIntervalForThisApp > maxRetryWaitSeconds) { + minRetryIntervalForThisApp = maxRetryWaitSeconds; + } if (minRetryIntervalForThisApp > retryAfterXSeconds) { retryAfterXSeconds = minRetryIntervalForThisApp; } } else { - toThrow.add(key, err, appName: errors?.appIdNames[key]); + if (err is! RateLimitError) { + toThrow.add(key, err, appName: errors?.appIdNames[key]); + } } }); } else { @@ -1624,37 +1623,32 @@ Future bgUpdateCheck(int taskId, Map? params) async { id: Random().nextInt(10000))); } } - // if there are update checks to retry, schedule a retry task + logs.add('BG update task: Done checking for updates.'); if (toRetry.isNotEmpty) { logs.add( 'BG update task $taskId: Will retry in $retryAfterXSeconds seconds.'); - AndroidAlarmManager.oneShot( - Duration(seconds: retryAfterXSeconds), taskId + 1, bgUpdateCheck, - params: { - 'toCheck': toRetry - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList(), - 'toInstall': toInstall - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList(), - }); + return await bgUpdateCheck(taskId, { + 'toCheck': toRetry + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + 'toInstall': toInstall + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList(), + }); } else { - // If there are no more update checks, schedule an install task - logs.add( - 'BG update task $taskId: Done. Scheduling install task to run immediately.'); - AndroidAlarmManager.oneShot( - const Duration(minutes: 0), taskId + 1, bgUpdateCheck, - params: { - 'toCheck': [], - 'toInstall': toInstall - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList() - }); + // If there are no more update checks, call the function in install mode + logs.add('BG update task: Done checking for updates.'); + return await bgUpdateCheck(taskId, { + 'toCheck': [], + 'toInstall': toInstall + .map((entry) => {'key': entry.key, 'value': entry.value}) + .toList() + }); } } else { // In install mode... - // If you haven't explicitly been given updates to install (which is the case for new tasks), grab all available silent updates + // If you haven't explicitly been given updates to install, grab all available silent updates if (toInstall.isEmpty && !networkRestricted) { var temp = appsProvider.findExistingUpdates(installedOnly: true); for (var i = 0; i < temp.length; i++) { @@ -1664,60 +1658,34 @@ Future bgUpdateCheck(int taskId, Map? params) async { } } } - var didCompleteInstalling = false; - var tempObtArr = toInstall.where((element) => element.key == obtainiumId); - if (tempObtArr.isNotEmpty) { - // Move obtainium to the end of the list as it must always install last - var obt = tempObtArr.first; - toInstall = moveStrToEndMapEntryWithCount(toInstall, obt); - } - // Loop through all updates and install each - for (var i = 0; i < toInstall.length; i++) { - var appId = toInstall[i].key; - var retryCount = toInstall[i].value; + if (toInstall.isNotEmpty) { + logs.add('BG install task: Started (${toInstall.length}).'); + var tempObtArr = toInstall.where((element) => element.key == obtainiumId); + if (tempObtArr.isNotEmpty) { + // Move obtainium to the end of the list as it must always install last + var obt = tempObtArr.first; + toInstall = moveStrToEndMapEntryWithCount(toInstall, obt); + } + // Loop through all updates and install each try { - logs.add( - 'BG install task $taskId: Attempting to update $appId in the background.'); - await appsProvider.downloadAndInstallLatestApps([appId], null, - notificationsProvider: notificationsProvider); - await Future.delayed(const Duration( - seconds: - 5)); // Just in case task ending causes install fail (not clear) - if (i == (toCheck.length - 1)) { - didCompleteInstalling = true; - } + await appsProvider.downloadAndInstallLatestApps( + toInstall.map((e) => e.key).toList(), null, + notificationsProvider: notificationsProvider, + forceParallelDownloads: true); } catch (e) { - // If you got an error, move the offender to the back of the line (increment their fail count) and schedule another task to continue installing shortly - logs.add( - 'BG install task $taskId: Got error on updating $appId \'${e.toString()}\'.'); - if (retryCount < maxAttempts) { - var remainingSeconds = retryCount; - logs.add( - 'BG install task $taskId: Will continue in $remainingSeconds seconds (with $appId moved to the end of the line).'); - var remainingToInstall = moveStrToEndMapEntryWithCount( - toInstall.sublist(i), MapEntry(appId, retryCount + 1)); - AndroidAlarmManager.oneShot( - Duration(seconds: remainingSeconds), taskId + 1, bgUpdateCheck, - params: { - 'toCheck': toCheck - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList(), - 'toInstall': remainingToInstall - .map((entry) => {'key': entry.key, 'value': entry.value}) - .toList(), - }); - break; + if (e is MultiAppMultiError) { + e.idsByErrorString.forEach((key, value) { + notificationsProvider.notify(ErrorCheckingUpdatesNotification( + e.errorsAppsString(key, value))); + }); } else { - // If the offender has reached its fail limit, notify the user and remove it from the list (task can continue) - toInstall.removeAt(i); - i--; - notificationsProvider - .notify(ErrorCheckingUpdatesNotification(e.toString())); + // We don't expect to ever get here in any situation so no need to catch (but log it in case) + logs.add('Fatal error in BG install task: ${e.toString()}'); + rethrow; } } - } - if (didCompleteInstalling || toInstall.isEmpty) { - logs.add('BG install task $taskId: Done.'); + logs.add('BG install task: Done installing updates.'); } } + appsProvider.settingsProvider.lastCompletedBGCheckTime = DateTime.now(); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 266f244..0fb6d2a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -52,8 +52,8 @@ class SettingsProvider with ChangeNotifier { } InstallMethodSettings get installMethod { - return InstallMethodSettings - .values[prefs?.getInt('installMethod') ?? InstallMethodSettings.normal.index]; + return InstallMethodSettings.values[ + prefs?.getInt('installMethod') ?? InstallMethodSettings.normal.index]; } set installMethod(InstallMethodSettings t) { @@ -345,15 +345,15 @@ class SettingsProvider with ChangeNotifier { notifyListeners(); } - DateTime get lastBGCheckTime { - int? temp = prefs?.getInt('lastBGCheckTime'); + DateTime get lastCompletedBGCheckTime { + int? temp = prefs?.getInt('lastCompletedBGCheckTime'); return temp != null ? DateTime.fromMillisecondsSinceEpoch(temp) : DateTime.fromMillisecondsSinceEpoch(0); } - set lastBGCheckTime(DateTime val) { - prefs?.setInt('lastBGCheckTime', val.millisecondsSinceEpoch); + set lastCompletedBGCheckTime(DateTime val) { + prefs?.setInt('lastCompletedBGCheckTime', val.millisecondsSinceEpoch); notifyListeners(); } diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 86ac8db..564770a 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -135,10 +135,28 @@ appJSONCompatibilityModifiers(Map json) { if (additionalSettings['autoApkFilterByArch'] == null) { additionalSettings['autoApkFilterByArch'] = false; } - // HTML 'fixed URL' support should be disabled if it previously did not exist - if (source.runtimeType == HTML().runtimeType && - originalAdditionalSettings['supportFixedAPKURL'] == null) { - additionalSettings['supportFixedAPKURL'] = false; + if (source.runtimeType == HTML().runtimeType) { + // HTML 'fixed URL' support should be disabled if it previously did not exist + if (originalAdditionalSettings['supportFixedAPKURL'] == null) { + additionalSettings['supportFixedAPKURL'] = false; + } + // HTML key rename + if (originalAdditionalSettings['sortByFileNamesNotLinks'] != null) { + additionalSettings['sortByLastLinkSegment'] = + originalAdditionalSettings['sortByFileNamesNotLinks']; + } + // HTML single 'intermediate link' should be converted to multi-support version + if (originalAdditionalSettings['intermediateLinkRegex'] != null && + additionalSettings['intermediateLink']?.isNotEmpty != true) { + additionalSettings['intermediateLink'] = [ + { + 'customLinkFilterRegex': + originalAdditionalSettings['intermediateLinkRegex'], + 'filterByLinkText': + originalAdditionalSettings['intermediateLinkByText'] + } + ]; + } } json['additionalSettings'] = jsonEncode(additionalSettings); // F-Droid no longer needs cloudflare exception since override can be used - migrate apps appropriately diff --git a/pubspec.lock b/pubspec.lock index 6bc1ea8..5a31918 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - android_alarm_manager_plus: - dependency: "direct main" - description: - name: android_alarm_manager_plus - sha256: "84720c8ad2758aabfbeafd24a8c355d8c8dd3aa52b01eaf3bb827c7210f61a91" - url: "https://pub.dev" - source: hosted - version: "3.0.4" android_intent_plus: dependency: "direct main" description: @@ -74,6 +66,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + background_fetch: + dependency: "direct main" + description: + name: background_fetch + sha256: f70b28a0f7a3156195e9742229696f004ea3bf10f74039b7bf4c78a74fbda8a4 + url: "https://pub.dev" + source: hosted + version: "1.2.1" boolean_selector: dependency: transitive description: @@ -299,10 +299,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: bb5cd63ff7c91d6efe452e41d0d0ae6348925c82eafd10ce170ef585ea04776e + sha256: "892ada16046d641263f30c72e7432397088810a84f34479f6677494802a2b535" url: "https://pub.dev" source: hosted - version: "16.2.0" + version: "16.3.0" flutter_local_notifications_linux: dependency: transitive description: @@ -514,10 +514,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_foundation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7b1bd76..511b9d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 0.14.41+235 # When changing this, update the tag in main() accordingly +version: 0.15.0+236 # When changing this, update the tag in main() accordingly environment: sdk: '>=3.0.0 <4.0.0' @@ -57,7 +57,6 @@ dependencies: ref: main android_package_manager: ^0.6.0 share_plus: ^7.0.0 - android_alarm_manager_plus: ^3.0.0 sqflite: ^2.2.0+3 easy_localization: ^3.0.1 android_intent_plus: ^4.0.0 @@ -68,6 +67,7 @@ dependencies: shared_storage: ^0.8.0 crypto: ^3.0.3 app_links: ^3.5.0 + background_fetch: ^1.2.1 dev_dependencies: flutter_test: