Version detection improvements, Mullvad web scraping fix and changelog addition, code readability improvements, general tweaks/bugfixes (#400)

1. Apps that don't have "standard" versioning formats now automatically stop using version detection. This will prevent users from having to learn about this feature and enable it manually.
    - For such Apps, the "standard" version detection option is greyed out.
2. The Mullvad Source recently broke due to a slight change in their website design. This is now fixed.
    - Mullvad also now provides an in-app changelog via their official GitHub repo.
3. Code has been refactored for readability (specifically the version detection code and UI code for most screens).
4. Minor UI tweaks and bugfixes.
This commit is contained in:
Imran Remtulla
2023-03-30 17:27:36 -04:00
committed by GitHub
parent c7cd35b6a1
commit 9fba747802
11 changed files with 1720 additions and 1873 deletions

View File

@@ -1,5 +1,7 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@@ -27,23 +29,24 @@ class Mullvad extends AppSource {
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android')); var details = await HTML().getLatestAPKDetails(
if (res.statusCode == 200) { '$standardUrl/en/download/android', additionalSettings);
var version = parse(res.body) var fileName = details.apkUrls[0].split('/').last;
.querySelector('p.subtitle.is-6') var versionMatch = RegExp('[0-9]+(\\.[0-9]+)+').firstMatch(fileName);
?.querySelector('a') if (versionMatch == null) {
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails( details.version = fileName.substring(versionMatch.start, versionMatch.end);
version, details.names = AppNames(name, 'Mullvad-VPN');
['https://mullvad.net/download/app/apk/latest'], try {
AppNames(name, 'Mullvad-VPN')); details.changeLog = (await GitHub().getLatestAPKDetails(
} else { 'https://github.com/mullvad/mullvadvpn-app',
throw getObtainiumHttpError(res); {'fallbackToOlderReleases': true}))
.changeLog;
} catch (e) {
print(e);
// Ignore
} }
return details;
} }
} }

View File

@@ -48,6 +48,7 @@ class GeneratedFormTextField extends GeneratedFormItem {
class GeneratedFormDropdown extends GeneratedFormItem { class GeneratedFormDropdown extends GeneratedFormItem {
late List<MapEntry<String, String>>? opts; late List<MapEntry<String, String>>? opts;
List<String>? disabledOptKeys;
GeneratedFormDropdown( GeneratedFormDropdown(
String key, String key,
@@ -55,6 +56,7 @@ class GeneratedFormDropdown extends GeneratedFormItem {
String label = 'Input', String label = 'Input',
List<Widget> belowWidgets = const [], List<Widget> belowWidgets = const [],
String defaultValue = '', String defaultValue = '',
this.disabledOptKeys,
List<String? Function(String? value)> additionalValidators = const [], List<String? Function(String? value)> additionalValidators = const [],
}) : super(key, }) : super(key,
label: label, label: label,
@@ -225,10 +227,15 @@ class _GeneratedFormState extends State<GeneratedForm> {
return DropdownButtonFormField( return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label), decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key], value: values[formItem.key],
items: formItem.opts! items: formItem.opts!.map((e2) {
.map((e2) => var enabled =
DropdownMenuItem(value: e2.key, child: Text(e2.value))) formItem.disabledOptKeys?.contains(e2.key) != true;
.toList(), return DropdownMenuItem(
value: e2.key,
enabled: enabled,
child: Opacity(
opacity: enabled ? 1 : 0.5, child: Text(e2.value)));
}).toList(),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
values[formItem.key] = value ?? formItem.opts!.first.key; values[formItem.key] = value ?? formItem.opts!.first.key;

View File

@@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:easy_localization/src/localization.dart'; import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.11.15'; const String currentVersion = '0.11.16';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES

View File

@@ -33,10 +33,10 @@ class _AddAppPageState extends State<AddAppPage> {
bool additionalSettingsValid = true; bool additionalSettingsValid = true;
List<String> pickedCategories = []; List<String> pickedCategories = [];
int searchnum = 0; int searchnum = 0;
SourceProvider sourceProvider = SourceProvider();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>(); AppsProvider appsProvider = context.read<AppsProvider>();
bool doingSomething = gettingAppInfo || searching; bool doingSomething = gettingAppInfo || searching;
@@ -64,15 +64,8 @@ class _AddAppPageState extends State<AddAppPage> {
} }
} }
addApp({bool resetUserInputAfter = false}) async { getTrackOnlyConfirmationIfNeeded(bool userPickedTrackOnly) async {
setState(() { return (!((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
var cont = true;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
await showDialog( await showDialog(
context: context, context: context,
@@ -88,10 +81,13 @@ class _AddAppPageState extends State<AddAppPage> {
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}', '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
); );
}) == }) ==
null) { null));
cont = false;
} }
if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' &&
getReleaseDateAsVersionConfirmationIfNeeded(
bool userPickedTrackOnly) async {
return (!(additionalSettings['versionDetection'] ==
'releaseDateAsVersion' &&
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
await showDialog( await showDialog(
context: context, context: context,
@@ -102,27 +98,22 @@ class _AddAppPageState extends State<AddAppPage> {
message: tr('releaseDateAsVersionExplanation'), message: tr('releaseDateAsVersionExplanation'),
); );
}) == }) ==
null) { null));
cont = false;
} }
if (additionalSettings['versionDetection'] == 'noVersionDetection' &&
// ignore: use_build_context_synchronously addApp({bool resetUserInputAfter = false}) async {
await showDialog( setState(() {
context: context, gettingAppInfo = true;
builder: (BuildContext ctx) { });
return GeneratedFormModal( try {
title: tr('disableVersionDetection'), var settingsProvider = context.read<SettingsProvider>();
items: const [], var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
message: tr('noVersionDetectionExplanation'), App? app;
); if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) &&
}) == (await getReleaseDateAsVersionConfirmationIfNeeded(
null) { userPickedTrackOnly))) {
cont = false;
}
if (cont) {
HapticFeedback.selectionClick();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp( app = await sourceProvider.getApp(
pickedSource!, userInput, additionalSettings, pickedSource!, userInput, additionalSettings,
trackOnlyOverride: trackOnly); trackOnlyOverride: trackOnly);
if (!trackOnly) { if (!trackOnly) {
@@ -150,38 +141,24 @@ class _AddAppPageState extends State<AddAppPage> {
} }
app.categories = pickedCategories; app.categories = pickedCategories;
await appsProvider.saveApps([app], onlyIfExists: false); await appsProvider.saveApps([app], onlyIfExists: false);
return app;
} }
}()
.then((app) {
if (app != null) { if (app != null) {
Navigator.push(globalNavigatorKey.currentContext ?? context, Navigator.push(globalNavigatorKey.currentContext ?? context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); MaterialPageRoute(builder: (context) => AppPage(appId: app!.id)));
} }
}).catchError((e) { } catch (e) {
showError(e, context); showError(e, context);
}).whenComplete(() { } finally {
setState(() { setState(() {
gettingAppInfo = false; gettingAppInfo = false;
if (resetUserInputAfter) { if (resetUserInputAfter) {
changeUserInput('', false, true); changeUserInput('', false, true);
} }
}); });
}); }
} }
return Scaffold( Widget getUrlInputRow() => Row(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: tr('addApp')),
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [ children: [
Expanded( Expanded(
child: GeneratedForm( child: GeneratedForm(
@@ -197,8 +174,7 @@ class _AddAppPageState extends State<AddAppPage> {
sourceProvider sourceProvider
.getSource(value ?? '') .getSource(value ?? '')
.standardizeURL( .standardizeURL(
preStandardizeUrl( preStandardizeUrl(value ?? ''));
value ?? ''));
} catch (e) { } catch (e) {
return e is String return e is String
? e ? e
@@ -212,8 +188,8 @@ class _AddAppPageState extends State<AddAppPage> {
] ]
], ],
onValueChanges: (values, valid, isBuilding) { onValueChanges: (values, valid, isBuilding) {
changeUserInput(values['appSourceURL']!, changeUserInput(
valid, isBuilding); values['appSourceURL']!, valid, isBuilding);
})), })),
const SizedBox( const SizedBox(
width: 16, width: 16,
@@ -223,47 +199,85 @@ class _AddAppPageState extends State<AddAppPage> {
: ElevatedButton( : ElevatedButton(
onPressed: doingSomething || onPressed: doingSomething ||
pickedSource == null || pickedSource == null ||
(pickedSource! (pickedSource!.combinedAppSpecificSettingFormItems
.combinedAppSpecificSettingFormItems
.isNotEmpty && .isNotEmpty &&
!additionalSettingsValid) !additionalSettingsValid)
? null ? null
: addApp, : () {
HapticFeedback.selectionClick();
addApp();
},
child: Text(tr('add'))) child: Text(tr('add')))
], ],
), );
if (sourceProvider.sources
runSearch() async {
setState(() {
searching = true;
});
try {
var results = await Future.wait(sourceProvider.sources
.where((e) => e.canSearch) .where((e) => e.canSearch)
.isNotEmpty && .map((e) => e.search(searchQuery)));
// .then((results) async {
// Interleave results instead of simple reduce
Map<String, String> res = {};
var si = 0;
var done = false;
while (!done) {
done = true;
for (var r in results) {
if (r.length > si) {
done = false;
res.addEntries([r.entries.elementAt(si)]);
}
}
si++;
}
List<String>? selectedUrls = res.isEmpty
? []
// ignore: use_build_context_synchronously
: await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: res,
selectedByDefault: false,
onlyOneSelectionAllowed: true,
);
});
if (selectedUrls != null && selectedUrls.isNotEmpty) {
changeUserInput(selectedUrls[0], true, false, isSearch: true);
}
} catch (e) {
showError(e, context);
} finally {
setState(() {
searching = false;
});
}
}
bool shouldShowSearchBar() =>
sourceProvider.sources.where((e) => e.canSearch).isNotEmpty &&
pickedSource == null && pickedSource == null &&
userInput.isEmpty) userInput.isEmpty;
const SizedBox(
height: 16, Widget getSearchBarRow() => Row(
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
Row(
children: [ children: [
Expanded( Expanded(
child: GeneratedForm( child: GeneratedForm(
items: [ items: [
[ [
GeneratedFormTextField( GeneratedFormTextField('searchSomeSources',
'searchSomeSources', label: tr('searchSomeSourcesLabel'), required: false),
label: tr('searchSomeSourcesLabel'),
required: false),
] ]
], ],
onValueChanges: (values, valid, isBuilding) { onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && if (values.isNotEmpty && valid && !isBuilding) {
valid &&
!isBuilding) {
setState(() { setState(() {
searchQuery = searchQuery = values['searchSomeSources']!.trim();
values['searchSomeSources']!.trim();
}); });
} }
}), }),
@@ -275,61 +289,13 @@ class _AddAppPageState extends State<AddAppPage> {
onPressed: searchQuery.isEmpty || doingSomething onPressed: searchQuery.isEmpty || doingSomething
? null ? null
: () { : () {
setState(() { runSearch();
searching = true;
});
Future.wait(sourceProvider.sources
.where((e) => e.canSearch)
.map((e) =>
e.search(searchQuery)))
.then((results) async {
// Interleave results instead of simple reduce
Map<String, String> res = {};
var si = 0;
var done = false;
while (!done) {
done = true;
for (var r in results) {
if (r.length > si) {
done = false;
res.addEntries(
[r.entries.elementAt(si)]);
}
}
si++;
}
List<String>? selectedUrls = res
.isEmpty
? []
: await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: res,
selectedByDefault: false,
onlyOneSelectionAllowed:
true,
);
});
if (selectedUrls != null &&
selectedUrls.isNotEmpty) {
changeUserInput(
selectedUrls[0], true, false,
isSearch: true);
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
searching = false;
});
});
}, },
child: Text(tr('search'))) child: Text(tr('search')))
], ],
), );
if (pickedSource != null)
Column( Widget getAdditionalOptsCol() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Divider( const Divider(
@@ -338,16 +304,13 @@ class _AddAppPageState extends State<AddAppPage> {
Text( Text(
tr('additionalOptsFor', tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]), args: [pickedSource?.name ?? tr('source')]),
style: TextStyle( style: TextStyle(color: Theme.of(context).colorScheme.primary)),
color:
Theme.of(context).colorScheme.primary)),
const SizedBox( const SizedBox(
height: 16, height: 16,
), ),
GeneratedForm( GeneratedForm(
key: Key(pickedSource.runtimeType.toString()), key: Key(pickedSource.runtimeType.toString()),
items: pickedSource! items: pickedSource!.combinedAppSpecificSettingFormItems,
.combinedAppSpecificSettingFormItems,
onValueChanges: (values, valid, isBuilding) { onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) { if (!isBuilding) {
setState(() { setState(() {
@@ -369,9 +332,9 @@ class _AddAppPageState extends State<AddAppPage> {
], ],
), ),
], ],
) );
else
Expanded( Widget getSourcesListWidget() => Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -389,10 +352,8 @@ class _AddAppPageState extends State<AddAppPage> {
.map((e) => GestureDetector( .map((e) => GestureDetector(
onTap: e.host != null onTap: e.host != null
? () { ? () {
launchUrlString( launchUrlString('https://${e.host}',
'https://${e.host}', mode: LaunchMode.externalApplication);
mode: LaunchMode
.externalApplication);
} }
: null, : null,
child: Text( child: Text(
@@ -404,7 +365,28 @@ class _AddAppPageState extends State<AddAppPage> {
fontStyle: FontStyle.italic), fontStyle: FontStyle.italic),
))) )))
.toList() .toList()
])), ]));
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
CustomAppBar(title: tr('addApp')),
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
getUrlInputRow(),
if (shouldShowSearchBar())
const SizedBox(
height: 16,
),
if (shouldShowSearchBar()) getSearchBarRow(),
if (pickedSource != null)
getAdditionalOptsCol()
else
getSourcesListWidget(),
const SizedBox( const SizedBox(
height: 8, height: 8,
), ),

View File

@@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
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/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart'; import 'package:obtainium/main.dart';
@@ -34,16 +35,22 @@ class _AppPageState extends State<AppPage> {
}); });
} }
bool areDownloadsRunning = appsProvider.areDownloadsRunning();
var sourceProvider = SourceProvider(); var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null; var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { if (!areDownloadsRunning && prevApp == null && app != null) {
prevApp = app; prevApp = app;
getUpdate(app.app.id); getUpdate(app.app.id);
} }
var trackOnly = app?.app.additionalSettings['trackOnly'] == true; var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
var infoColumn = Column( bool isVersionDetectionStandard =
app?.app.additionalSettings['versionDetection'] ==
'standardVersionDetection';
getInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -66,19 +73,35 @@ class _AppPageState extends State<AppPage> {
height: 32, height: 32,
), ),
Text( Text(
tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]), tr('latestVersionX',
args: [app?.app.latestVersion ?? tr('unknown')]),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
Text( Text(
'${tr('installedVersionX', args: [ '${tr('installedVersionX', args: [
app?.app.installedVersion ?? tr('none') app?.installedInfo?.versionName ??
app?.app.installedVersion ??
tr('none')
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [ ])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
tr('app') tr('app')
])}' : ''}', ])}' : ''}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (app?.app.installedVersion != null &&
!isVersionDetectionStandard)
Column(
children: [
const SizedBox(
height: 4,
),
Text(
tr('noVersionDetection'),
style: Theme.of(context).textTheme.labelSmall,
)
],
),
const SizedBox( const SizedBox(
height: 32, height: 32,
), ),
@@ -96,8 +119,9 @@ class _AppPageState extends State<AppPage> {
), ),
CategoryEditorSelector( CategoryEditorSelector(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
preselected: preselected: app?.app.categories != null
app?.app.categories != null ? app!.app.categories.toSet() : {}, ? app!.app.categories.toSet()
: {},
onSelected: (categories) { onSelected: (categories) {
if (app != null) { if (app != null) {
app.app.categories = categories; app.app.categories = categories;
@@ -107,7 +131,7 @@ class _AppPageState extends State<AppPage> {
], ],
); );
var fullInfoColumn = Column( getFullInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -152,51 +176,162 @@ class _AppPageState extends State<AppPage> {
const SizedBox( const SizedBox(
height: 32, height: 32,
), ),
infoColumn, getInfoColumn(),
const SizedBox(height: 150) const SizedBox(height: 150)
], ],
); );
return Scaffold( getAppWebView() => app != null
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? app != null
? WebViewWidget( ? WebViewWidget(
controller: WebViewController() controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor( ..setBackgroundColor(Theme.of(context).colorScheme.background)
Theme.of(context).colorScheme.background)
..setJavaScriptMode(JavaScriptMode.unrestricted) ..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate( ..setNavigationDelegate(
NavigationDelegate( NavigationDelegate(
onWebResourceError: (WebResourceError error) { onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) { if (error.isForMainFrame == true) {
showError( showError(
ObtainiumError(error.description, ObtainiumError(error.description, unexpected: true),
unexpected: true),
context); context);
} }
}, },
), ),
) )
..loadRequest(Uri.parse(app.app.url))) ..loadRequest(Uri.parse(app.app.url)))
: Container() : Container();
: CustomScrollView(
slivers: [ showMarkUpdatedDialog() {
SliverToBoxAdapter( return showDialog(
child: Column(children: [fullInfoColumn])), context: context,
], builder: (BuildContext ctx) {
), return AlertDialog(
onRefresh: () async { title: Text(tr('alreadyUpToDateQuestion')),
if (app != null) { actions: [
getUpdate(app.app.id); TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('no'))),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion = updatedApp.latestVersion;
appsProvider.saveApps([updatedApp]);
} }
}), Navigator.of(context).pop();
bottomSheet: Padding( },
padding: EdgeInsets.fromLTRB( child: Text(tr('yesMarkUpdated')))
0, 0, 0, MediaQuery.of(context).padding.bottom), ],
);
});
}
showAdditionalOptionsDialog() async {
return await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
var items =
(source?.combinedAppSpecificSettingFormItems ?? []).map((row) {
row = row.map((e) {
if (app?.app.additionalSettings[e.key] != null) {
e.defaultValue = app?.app.additionalSettings[e.key];
}
return e;
}).toList();
return row;
}).toList();
items = items.map((row) {
row = row.map((e) {
if (e.key == 'versionDetection' && e is GeneratedFormDropdown) {
e.disabledOptKeys ??= [];
if (app?.app.installedVersion != null &&
!appsProvider.isVersionDetectionPossible(app)) {
e.disabledOptKeys!.add('standardVersionDetection');
}
if (app?.app.releaseDate == null) {
e.disabledOptKeys!.add('releaseDateAsVersion');
}
}
return e;
}).toList();
return row;
}).toList();
return GeneratedFormModal(
title: tr('additionalOptions'),
items: items,
);
});
}
handleAdditionalOptionChanges(Map<String, dynamic>? values) {
if (app != null && values != null) {
Map<String, dynamic> originalSettings = app.app.additionalSettings;
app.app.additionalSettings = values;
if (source?.enforceTrackOnly == true) {
app.app.additionalSettings['trackOnly'] = true;
// ignore: use_build_context_synchronously
showError(tr('appsFromSourceAreTrackOnly'), context);
}
if (app.app.additionalSettings['versionDetection'] ==
'releaseDateAsVersion') {
if (originalSettings['versionDetection'] != 'releaseDateAsVersion') {
if (app.app.releaseDate != null) {
bool isUpdated =
app.app.installedVersion == app.app.latestVersion;
app.app.latestVersion =
app.app.releaseDate!.microsecondsSinceEpoch.toString();
if (isUpdated) {
app.app.installedVersion = app.app.latestVersion;
}
}
}
} else if (originalSettings['versionDetection'] ==
'releaseDateAsVersion') {
app.app.installedVersion =
app.installedInfo?.versionName ?? app.app.installedVersion;
}
appsProvider.saveApps([app.app]).then((value) {
getUpdate(app.app.id);
});
}
}
getInstallOrUpdateButton() => TextButton(
onPressed: (app?.app.installedVersion == null ||
app?.app.installedVersion != app?.app.latestVersion) &&
!areDownloadsRunning
? () async {
try {
HapticFeedback.heavyImpact();
if (app?.app.additionalSettings['trackOnly'] != true) {
await settingsProvider.getInstallPermission();
}
var res = await appsProvider.downloadAndInstallLatestApps(
[app!.app.id], globalNavigatorKey.currentContext);
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
} catch (e) {
showError(e, context);
}
}
: null,
child: Text(app?.app.installedVersion == null
? !trackOnly
? tr('install')
: tr('markInstalled')
: !trackOnly
? tr('update')
: tr('markUpdated')));
getBottomSheetMenu() => Padding(
padding:
EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -205,129 +340,25 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.additionalSettings['versionDetection'] != if (app?.app.installedVersion != null &&
'standardVersionDetection' && app?.app.installedVersion != app?.app.latestVersion &&
!trackOnly && !isVersionDetectionStandard &&
app?.app.installedVersion != null && !trackOnly)
app?.app.installedVersion != app?.app.latestVersion)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : showMarkUpdatedDialog,
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(tr(
'alreadyUpToDateQuestion')),
actions: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop();
},
child: Text(tr('no'))),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp
.installedVersion =
updatedApp
.latestVersion;
appsProvider.saveApps(
[updatedApp]);
}
Navigator.of(context)
.pop();
},
child: Text(
tr('yesMarkUpdated')))
],
);
});
},
tooltip: tr('markUpdated'), tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)), icon: const Icon(Icons.done)),
if (source != null && if (source != null &&
source source.combinedAppSpecificSettingFormItems.isNotEmpty)
.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () async {
showDialog<Map<String, dynamic>?>( var values =
context: context, await showAdditionalOptionsDialog();
builder: (BuildContext ctx) { handleAdditionalOptionChanges(values);
var items = source
.combinedAppSpecificSettingFormItems
.map((row) {
row.map((e) {
if (app?.app.additionalSettings[
e.key] !=
null) {
e.defaultValue = app?.app
.additionalSettings[
e.key];
}
return e;
}).toList();
return row;
}).toList();
return GeneratedFormModal(
title: tr('additionalOptions'),
items: items,
);
}).then((values) {
if (app != null && values != null) {
Map<String, dynamic>
originalSettings =
app.app.additionalSettings;
app.app.additionalSettings = values;
if (source.enforceTrackOnly) {
app.app.additionalSettings[
'trackOnly'] = true;
showError(
tr('appsFromSourceAreTrackOnly'),
context);
}
if (app.app.additionalSettings[
'versionDetection'] ==
'releaseDateAsVersion') {
if (originalSettings[
'versionDetection'] !=
'releaseDateAsVersion') {
if (app.app.releaseDate != null) {
bool isUpdated =
app.app.installedVersion ==
app.app.latestVersion;
app.app.latestVersion = app
.app
.releaseDate!
.microsecondsSinceEpoch
.toString();
if (isUpdated) {
app.app.installedVersion =
app.app.latestVersion;
}
}
}
} else if (originalSettings[
'versionDetection'] ==
'releaseDateAsVersion') {
app.app.installedVersion = app
.installedInfo
?.versionName ??
app.app.installedVersion;
}
appsProvider.saveApps([app.app]).then(
(value) {
getUpdate(app.app.id);
});
}
});
}, },
tooltip: tr('additionalOptions'), tooltip: tr('additionalOptions'),
icon: const Icon(Icons.edit)), icon: const Icon(Icons.edit)),
@@ -347,7 +378,7 @@ class _AppPageState extends State<AppPage> {
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
content: infoColumn, content: getInfoColumn(),
title: Text( title: Text(
'${app.app.name} ${tr('byX', args: [ '${app.app.name} ${tr('byX', args: [
app.app.author app.app.author
@@ -365,47 +396,7 @@ class _AppPageState extends State<AppPage> {
icon: const Icon(Icons.more_horiz), icon: const Icon(Icons.more_horiz),
tooltip: tr('more')), tooltip: tr('more')),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
Expanded( Expanded(child: getInstallOrUpdateButton()),
child: TextButton(
onPressed: (app?.app.installedVersion == null ||
app?.app.installedVersion !=
app?.app.latestVersion) &&
!appsProvider.areDownloadsRunning()
? () {
HapticFeedback.heavyImpact();
() async {
if (app?.app.additionalSettings[
'trackOnly'] !=
true) {
await settingsProvider
.getInstallPermission();
}
}()
.then((value) {
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
globalNavigatorKey
.currentContext).then(
(res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
}).catchError((e) {
showError(e, context);
});
}).catchError((e) {
showError(e, context);
});
}
: null,
child: Text(app?.app.installedVersion == null
? !trackOnly
? tr('install')
: tr('markInstalled')
: !trackOnly
? tr('update')
: tr('markUpdated')))),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
Expanded( Expanded(
child: TextButton( child: TextButton(
@@ -433,7 +424,25 @@ class _AppPageState extends State<AppPage> {
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: app!.downloadProgress! / 100)) value: app!.downloadProgress! / 100))
], ],
)), ));
);
return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? getAppWebView()
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()])),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
}),
bottomSheet: getBottomSheetMenu());
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var settingsProvider = context.read<SettingsProvider>(); var settingsProvider = context.read<SettingsProvider>();
var outlineButtonStyle = ButtonStyle( var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all( shape: MaterialStateProperty.all(
StadiumBorder( StadiumBorder(
@@ -101,6 +102,193 @@ class _ImportExportPageState extends State<ImportExportPage> {
}); });
} }
runObtainiumExport() {
HapticFeedback.selectionClick();
appsProvider.exportApps().then((String path) {
showError(tr('exportedTo', args: [path]), context);
}).catchError((e) {
showError(e, context);
});
}
runObtainiumImport() {
HapticFeedback.selectionClick();
FilePicker.platform.pickFiles().then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(result.files.single.path!).readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(tr('invalidInput'));
}
appsProvider.importApps(data).then((value) {
var cats = settingsProvider.categories;
appsProvider.apps.forEach((key, value) {
for (var c in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] = generateRandomLightColor().value;
}
}
});
settingsProvider.categories = cats;
showError(tr('importedX', args: [plural('apps', value)]), context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
runUrlImport() {
FilePicker.platform.pickFiles().then((result) {
if (result != null) {
urlListImport(
overrideInitValid: true,
initValue: RegExp('https?://[^"]+')
.allMatches(
File(result.files.single.path!).readAsStringSync())
.map((e) => e.input.substring(e.start, e.end))
.toSet()
.toList()
.where((url) {
try {
sourceProvider.getSource(url);
return true;
} catch (e) {
return false;
}
}).join('\n'));
}
});
}
runSourceSearch(AppSource source) {
() async {
var values = await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX', args: [source.name]),
items: [
[
GeneratedFormTextField('searchQuery',
label: tr('searchQuery'))
]
],
);
});
if (values != null &&
(values['searchQuery'] as String?)?.isNotEmpty == true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source.search(values['searchQuery'] as String);
if (urlsWithDescriptions.isNotEmpty) {
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: urlsWithDescriptions,
selectedByDefault: false,
);
});
if (selectedUrls != null && selectedUrls.isNotEmpty) {
var errors = await appsProvider.addAppsByURL(selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX', args: [plural('app', selectedUrls.length)]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: selectedUrls.length, errors: errors);
});
}
}
} else {
throw ObtainiumError(tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
runMassSourceImport(MassAppUrlSource source) {
() async {
var values = await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX', args: [source.name]),
items: source.requiredArgs
.map((e) => [GeneratedFormTextField(e, label: e)])
.toList(),
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions = await source.getUrlsWithDescriptions(
values.values.map((e) => e.toString()).toList());
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors = await appsProvider.addAppsByURL(selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX', args: [plural('app', selectedUrls.length)]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: selectedUrls.length, errors: errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@@ -120,18 +308,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: appsProvider.apps.isEmpty || onPressed: appsProvider.apps.isEmpty ||
importInProgress importInProgress
? null ? null
: () { : runObtainiumExport,
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
showError(
tr('exportedTo', args: [path]),
context);
}).catchError((e) {
showError(e, context);
});
},
child: Text(tr('obtainiumExport')))), child: Text(tr('obtainiumExport')))),
const SizedBox( const SizedBox(
width: 16, width: 16,
@@ -141,59 +318,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
style: outlineButtonStyle, style: outlineButtonStyle,
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : runObtainiumImport,
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(
tr('invalidInput'));
}
appsProvider
.importApps(data)
.then((value) {
var cats =
settingsProvider.categories;
appsProvider.apps
.forEach((key, value) {
for (var c
in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] =
generateRandomLightColor()
.value;
}
}
});
settingsProvider.categories =
cats;
showError(
tr('importedX', args: [
plural('apps', value)
]),
context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: Text(tr('obtainiumImport')))) child: Text(tr('obtainiumImport'))))
], ],
), ),
@@ -216,49 +341,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
height: 32, height: 32,
), ),
TextButton( TextButton(
onPressed: importInProgress onPressed:
? null importInProgress ? null : urlListImport,
: () {
urlListImport();
},
child: Text( child: Text(
tr('importFromURLList'), tr('importFromURLList'),
)), )),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: importInProgress onPressed:
? null importInProgress ? null : runUrlImport,
: () {
FilePicker.platform
.pickFiles()
.then((result) {
if (result != null) {
urlListImport(
overrideInitValid: true,
initValue:
RegExp('https?://[^"]+')
.allMatches(File(result
.files
.single
.path!)
.readAsStringSync())
.map((e) =>
e.input.substring(
e.start, e.end))
.toSet()
.toList()
.where((url) {
try {
sourceProvider
.getSource(url);
return true;
} catch (e) {
return false;
}
}).join('\n'));
}
});
},
child: Text( child: Text(
tr('importFromURLsInFile'), tr('importFromURLsInFile'),
)), )),
@@ -275,106 +366,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
() async { runSourceSearch(source);
var values = await showDialog<
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX',
args: [
source.name
]),
items: [
[
GeneratedFormTextField(
'searchQuery',
label: tr(
'searchQuery'))
]
],
);
});
if (values != null &&
(values['searchQuery']
as String?)
?.isNotEmpty ==
true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source.search(
values['searchQuery']
as String);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<
List<
String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
selectedByDefault:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}, },
child: Text( child: Text(
tr('searchX', args: [source.name]))) tr('searchX', args: [source.name])))
@@ -390,93 +382,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
() async { runMassSourceImport(source);
var values = await showDialog<
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX',
args: [
source.name
]),
items:
source
.requiredArgs
.map(
(e) => [
GeneratedFormTextField(e,
label: e)
])
.toList(),
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values.values
.map((e) =>
e.toString())
.toList());
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}, },
child: Text( child: Text(
tr('importX', args: [source.name]))) tr('importX', args: [source.name])))

View File

@@ -73,6 +73,18 @@ List<String> generateStandardVersionRegExStrings() {
List<String> standardVersionRegExStrings = List<String> standardVersionRegExStrings =
generateStandardVersionRegExStrings(); generateStandardVersionRegExStrings();
Set<String> findStandardFormatsForVersion(String version, bool strict) {
// If !strict, even a substring match is valid
Set<String> results = {};
for (var pattern in standardVersionRegExStrings) {
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
.hasMatch(version)) {
results.add(pattern);
}
}
return results;
}
class AppsProvider with ChangeNotifier { class AppsProvider with ChangeNotifier {
// In memory App state (should always be kept in sync with local storage versions) // In memory App state (should always be kept in sync with local storage versions)
Map<String, AppInMemory> apps = {}; Map<String, AppInMemory> apps = {};
@@ -472,55 +484,106 @@ class AppsProvider with ChangeNotifier {
return res; return res;
} }
// If the App says it is installed but installedInfo is null, set it to not installed bool isVersionDetectionPossible(AppInMemory? app) {
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently return app?.app.additionalSettings['trackOnly'] != true &&
// If that fails, just set it to the actual version string (all we can do at that point) app?.installedInfo?.versionName != null &&
// Don't save changes, just return the object if changes were made (else null) app?.app.installedVersion != null &&
reconcileVersionDifferences(
app!.installedInfo!.versionName!, app.app.installedVersion!) !=
null;
}
// Given an App and it's on-device info...
// Reconcile unexpected differences between its reported installed version, real installed version, and reported latest version
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
var modded = false; var modded = false;
var trackOnly = app.additionalSettings['trackOnly'] == true; var trackOnly = app.additionalSettings['trackOnly'] == true;
var noVersionDetection = app.additionalSettings['versionDetection'] != var noVersionDetection = app.additionalSettings['versionDetection'] !=
'standardVersionDetection'; 'standardVersionDetection';
// FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL
if (installedInfo == null && app.installedVersion != null && !trackOnly) { if (installedInfo == null && app.installedVersion != null && !trackOnly) {
// App says it's installed but isn't really (and isn't track only) - set to not installed
app.installedVersion = null; app.installedVersion = null;
modded = true; modded = true;
} else if (installedInfo?.versionName != null && } else if (installedInfo?.versionName != null &&
app.installedVersion == null) { app.installedVersion == null) {
// App says it's not installed but really is - set to installed and use real package versionName
app.installedVersion = installedInfo!.versionName; app.installedVersion = installedInfo!.versionName;
modded = true; modded = true;
} else if (installedInfo?.versionName != null && }
// SECOND, RECONCILE DIFFERENCES BETWEEN THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE NEITHER IS NULL
if (installedInfo?.versionName != null &&
installedInfo!.versionName != app.installedVersion && installedInfo!.versionName != app.installedVersion &&
!noVersionDetection) { !noVersionDetection) {
String? correctedInstalledVersion = reconcileRealAndInternalVersions( // App's reported version and real version don't match (and it uses standard version detection)
// If they share a standard format (and are still different under it), update the reported version accordingly
var correctedInstalledVersion = reconcileVersionDifferences(
installedInfo.versionName!, app.installedVersion!); installedInfo.versionName!, app.installedVersion!);
if (correctedInstalledVersion != null) { if (correctedInstalledVersion?.key == false) {
app.installedVersion = correctedInstalledVersion; app.installedVersion = correctedInstalledVersion!.value;
modded = true; modded = true;
} }
} }
// THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS
if (app.installedVersion != null && if (app.installedVersion != null &&
app.installedVersion != app.latestVersion && app.installedVersion != app.latestVersion &&
!noVersionDetection) { !noVersionDetection) {
app.installedVersion = reconcileRealAndInternalVersions( // App's reported installed and latest versions don't match (and it uses standard version detection)
app.installedVersion!, app.latestVersion, // If they share a standard format, make sure the App's reported installed version uses that format
matchMode: true) ?? var correctedInstalledVersion =
app.installedVersion; reconcileVersionDifferences(app.installedVersion!, app.latestVersion);
if (correctedInstalledVersion?.key == true) {
app.installedVersion = correctedInstalledVersion!.value;
modded = true; modded = true;
} }
}
// FOURTH, DISABLE VERSION DETECTION IF ENABLED AND THE REPORTED/REAL INSTALLED VERSIONS ARE NOT STANDARDIZED
if (installedInfo != null &&
!isVersionDetectionPossible(AppInMemory(app, null, installedInfo))) {
app.additionalSettings['versionDetection'] = 'noVersionDetection';
logs.add('Could not reconcile version formats for: ${app.id}');
modded = true;
}
// if (app.installedVersion != null &&
// app.additionalSettings['versionDetection'] ==
// 'standardVersionDetection') {
// var correctedInstalledVersion =
// reconcileVersionDifferences(app.installedVersion!, app.latestVersion);
// if (correctedInstalledVersion == null) {
// app.additionalSettings['versionDetection'] = 'noVersionDetection';
// logs.add('Could not reconcile version formats for: ${app.id}');
// modded = true;
// }
// }
return modded ? app : null; return modded ? app : null;
} }
String? reconcileRealAndInternalVersions( MapEntry<bool, String>? reconcileVersionDifferences(
String realVersion, String internalVersion, String templateVersion, String comparisonVersion) {
{bool matchMode = false}) { // Returns null if the versions don't share a common standard format
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is) // Returns <true, comparisonVersion> if they share a common format and are equal
// 2. If both have a "standard" format under which they are equal, return null (leave as is) // Returns <false, templateVersion> if they share a common format but are not equal
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally) // templateVersion must fully match a standard format, while comparisonVersion can have a substring match
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly var templateVersionFormats =
// Matchmode to be used when comparing internal install version and internal latest version findStandardFormatsForVersion(templateVersion, true);
var comparisonVersionFormats =
findStandardFormatsForVersion(comparisonVersion, false);
var commonStandardFormats =
templateVersionFormats.intersection(comparisonVersionFormats);
if (commonStandardFormats.isEmpty) {
return null;
}
for (String pattern in commonStandardFormats) {
if (doStringsMatchUnderRegEx(
pattern, comparisonVersion, templateVersion)) {
return MapEntry(true, comparisonVersion);
}
}
return MapEntry(false, templateVersion);
}
bool doStringsMatchUnderRegEx( bool doStringsMatchUnderRegEx(String pattern, String value1, String value2) {
String pattern, String value1, String value2) {
var r = RegExp(pattern); var r = RegExp(pattern);
var m1 = r.firstMatch(value1); var m1 = r.firstMatch(value1);
var m2 = r.firstMatch(value2); var m2 = r.firstMatch(value2);
@@ -530,38 +593,6 @@ class AppsProvider with ChangeNotifier {
: false; : false;
} }
Set<String> findStandardFormatsForVersion(String version, bool strict) {
Set<String> results = {};
for (var pattern in standardVersionRegExStrings) {
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
.hasMatch(version)) {
results.add(pattern);
}
}
return results;
}
var realStandardVersionFormats =
findStandardFormatsForVersion(realVersion, true);
var internalStandardVersionFormats =
findStandardFormatsForVersion(internalVersion, false);
var commonStandardFormats =
realStandardVersionFormats.intersection(internalStandardVersionFormats);
if (commonStandardFormats.isEmpty) {
return null; // Incompatible; no "enhanced detection"
}
for (String pattern in commonStandardFormats) {
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) {
return matchMode
? internalVersion
: null; // Enhanced detection says no change
}
}
return matchMode
? null
: realVersion; // Enhanced detection says something changed
}
Future<void> loadApps() async { Future<void> loadApps() async {
while (loadingApps) { while (loadingApps) {
await Future.delayed(const Duration(microseconds: 1)); await Future.delayed(const Duration(microseconds: 1));

View File

@@ -21,7 +21,6 @@ import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/app_sources/steammobile.dart'; import 'package:obtainium/app_sources/steammobile.dart';
import 'package:obtainium/app_sources/telegramapp.dart'; import 'package:obtainium/app_sources/telegramapp.dart';
import 'package:obtainium/app_sources/vlc.dart'; import 'package:obtainium/app_sources/vlc.dart';
import 'package:obtainium/app_sources/whatsapp.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart';
@@ -111,7 +110,6 @@ class App {
// Convert bool style version detection options to dropdown style // Convert bool style version detection options to dropdown style
if (additionalSettings['noVersionDetection'] == true) { if (additionalSettings['noVersionDetection'] == true) {
additionalSettings['versionDetection'] = 'noVersionDetection'; additionalSettings['versionDetection'] = 'noVersionDetection';
}
if (additionalSettings['releaseDateAsVersion'] == true) { if (additionalSettings['releaseDateAsVersion'] == true) {
additionalSettings['versionDetection'] = 'releaseDateAsVersion'; additionalSettings['versionDetection'] = 'releaseDateAsVersion';
additionalSettings.remove('releaseDateAsVersion'); additionalSettings.remove('releaseDateAsVersion');
@@ -122,6 +120,7 @@ class App {
if (additionalSettings['releaseDateAsVersion'] != null) { if (additionalSettings['releaseDateAsVersion'] != null) {
additionalSettings.remove('releaseDateAsVersion'); additionalSettings.remove('releaseDateAsVersion');
} }
}
// Ensure additionalSettings are correctly typed // Ensure additionalSettings are correctly typed
for (var item in formItems) { for (var item in formItems) {
if (additionalSettings[item.key] != null) { if (additionalSettings[item.key] != null) {

View File

@@ -409,10 +409,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "04890b994ee89bfa80bf3080bfec40d5a92c5c7a785ebb02c13084a099d2b6f9" sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.13" version: "2.0.14"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
@@ -425,10 +425,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "12eee51abdf4d34c590f043f45073adbb45514a108bd9db4491547a2fd891059" sha256: "818b2dc38b0f178e0ea3f7cf3b28146faab11375985d815942a68eee11c2d0f7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.1"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -473,10 +473,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163" sha256: ee96ac32f5a8e6f80756e25b25b9f8e535816c8e6665a96b6d70681f8c4f7e85
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.7" version: "9.0.8"
permission_handler_platform_interface: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -553,10 +553,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: ee6257848f822b8481691f20c3e6d2bfee2e9eccb2a3d249907fcfb198c55b41 sha256: "78528fd87d0d08ffd3e69551173c026e8eacc7b7079c82eb6a77413957b7e394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.18" version: "2.0.20"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
@@ -585,10 +585,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "824bfd02713e37603b2bdade0842e47d56e7db32b1dcdd1cae533fb88e2913fc" sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.2.0"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@@ -710,10 +710,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "845530e5e05db5500c1a4c1446785d60cbd8f9bd45e21e7dd643a3273bb4bbd1" sha256: dd729390aa936bf1bdf5cd1bc7468ff340263f80a2c4f569416507667de8e3c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.25" version: "6.0.26"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@@ -806,10 +806,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_wkwebview name: webview_flutter_wkwebview
sha256: ab12479f7a0cf112b9420c36aaf206a1ca47cd60cd42de74a4be2e97a697587b sha256: d601aba11ad8d4481e17a34a76fa1d30dee92dcbbe2c58b0df3120e9453099c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.3"
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.11.15+136 # When changing this, update the tag in main() accordingly version: 0.11.16+138 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'