Files
Obtainium/lib/providers/source_provider.dart
Imran Remtulla 9fba747802 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.
2023-03-30 17:27:36 -04:00

474 lines
15 KiB
Dart

// Defines App sources and provides functions used to interact with them
// AppSource is an abstract class with a concrete implementation for each source
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/codeberg.dart';
import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/app_sources/fdroidrepo.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/neutroncode.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/app_sources/steammobile.dart';
import 'package:obtainium/app_sources/telegramapp.dart';
import 'package:obtainium/app_sources/vlc.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames {
late String author;
late String name;
AppNames(this.author, this.name);
}
class APKDetails {
late String version;
late List<String> apkUrls;
late AppNames names;
late DateTime? releaseDate;
late String? changeLog;
APKDetails(this.version, this.apkUrls, this.names,
{this.releaseDate, this.changeLog});
}
class App {
late String id;
late String url;
late String author;
late String name;
String? installedVersion;
late String latestVersion;
List<String> apkUrls = [];
late int preferredApkIndex;
late Map<String, dynamic> additionalSettings;
late DateTime? lastUpdateCheck;
bool pinned = false;
List<String> categories;
late DateTime? releaseDate;
late String? changeLog;
App(
this.id,
this.url,
this.author,
this.name,
this.installedVersion,
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalSettings,
this.lastUpdateCheck,
this.pinned,
{this.categories = const [],
this.releaseDate,
this.changeLog});
@override
String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALSETTINGS: ${additionalSettings.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
}
factory App.fromJson(Map<String, dynamic> json) {
var source = SourceProvider().getSource(json['url']);
var formItems = source.combinedAppSpecificSettingFormItems
.reduce((value, element) => [...value, ...element]);
Map<String, dynamic> additionalSettings =
getDefaultValuesFromFormItems([formItems]);
if (json['additionalSettings'] != null) {
additionalSettings.addEntries(
Map<String, dynamic>.from(jsonDecode(json['additionalSettings']))
.entries);
}
// If needed, migrate old-style additionalData to newer-style additionalSettings (V1)
if (json['additionalData'] != null) {
List<String> temp = List<String>.from(jsonDecode(json['additionalData']));
temp.asMap().forEach((i, value) {
if (i < formItems.length) {
if (formItems[i] is GeneratedFormSwitch) {
additionalSettings[formItems[i].key] = value == 'true';
} else {
additionalSettings[formItems[i].key] = value;
}
}
});
additionalSettings['trackOnly'] =
json['trackOnly'] == 'true' || json['trackOnly'] == true;
additionalSettings['noVersionDetection'] =
json['noVersionDetection'] == 'true' || json['trackOnly'] == true;
}
// Convert bool style version detection options to dropdown style
if (additionalSettings['noVersionDetection'] == true) {
additionalSettings['versionDetection'] = 'noVersionDetection';
if (additionalSettings['releaseDateAsVersion'] == true) {
additionalSettings['versionDetection'] = 'releaseDateAsVersion';
additionalSettings.remove('releaseDateAsVersion');
}
if (additionalSettings['noVersionDetection'] != null) {
additionalSettings.remove('noVersionDetection');
}
if (additionalSettings['releaseDateAsVersion'] != null) {
additionalSettings.remove('releaseDateAsVersion');
}
}
// Ensure additionalSettings are correctly typed
for (var item in formItems) {
if (additionalSettings[item.key] != null) {
additionalSettings[item.key] =
item.ensureType(additionalSettings[item.key]);
}
}
int preferredApkIndex = json['preferredApkIndex'] == null
? 0
: json['preferredApkIndex'] as int;
if (preferredApkIndex < 0) {
preferredApkIndex = 0;
}
return App(
json['id'] as String,
json['url'] as String,
json['author'] as String,
json['name'] as String,
json['installedVersion'] == null
? null
: json['installedVersion'] as String,
json['latestVersion'] as String,
json['apkUrls'] == null
? []
: List<String>.from(jsonDecode(json['apkUrls'])),
preferredApkIndex,
additionalSettings,
json['lastUpdateCheck'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false,
categories: json['categories'] != null
? (json['categories'] as List<dynamic>)
.map((e) => e.toString())
.toList()
: json['category'] != null
? [json['category'] as String]
: [],
releaseDate: json['releaseDate'] == null
? null
: DateTime.fromMicrosecondsSinceEpoch(json['releaseDate']),
changeLog:
json['changeLog'] == null ? null : json['changeLog'] as String);
}
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'author': author,
'name': name,
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex,
'additionalSettings': jsonEncode(additionalSettings),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned,
'categories': categories,
'releaseDate': releaseDate?.microsecondsSinceEpoch,
'changeLog': changeLog
};
}
// Ensure the input is starts with HTTPS and has no WWW
preStandardizeUrl(String url) {
var firstDotIndex = url.indexOf('.');
if (!(firstDotIndex >= 0 && firstDotIndex != url.length - 1)) {
throw UnsupportedURLError();
}
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
url = url
.split('/')
.where((e) => e.isNotEmpty)
.join('/')
.replaceFirst(':/', '://');
return url;
}
String noAPKFound = tr('noAPKFound');
List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) =>
dom
.querySelectorAll('a')
.where((element) {
if (element.attributes['href'] == null) return false;
return hrefPattern.hasMatch(element.attributes['href']!);
})
.map((e) => '$prependToLinks${e.attributes['href']!}')
.toList();
Map<String, dynamic> getDefaultValuesFromFormItems(
List<List<GeneratedFormItem>> items) {
return Map.fromEntries(items
.map((row) => row.map((el) => MapEntry(el.key, el.defaultValue ?? '')))
.reduce((value, element) => [...value, ...element]));
}
class AppSource {
String? host;
late String name;
bool enforceTrackOnly = false;
bool changeLogIfAnyIsMarkDown = true;
AppSource() {
name = runtimeType.toString();
}
String standardizeURL(String url) {
throw NotImplementedError();
}
Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, dynamic> additionalSettings) {
throw NotImplementedError();
}
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificSettingFormItems =
[];
// Some additional data may be needed for Apps regardless of Source
final List<List<GeneratedFormItem>>
additionalAppSpecificSourceAgnosticSettingFormItems = [
[
GeneratedFormSwitch(
'trackOnly',
label: tr('trackOnly'),
)
],
[
GeneratedFormDropdown(
'versionDetection',
[
MapEntry(
'standardVersionDetection', tr('standardVersionDetection')),
MapEntry('releaseDateAsVersion', tr('releaseDateAsVersion')),
MapEntry('noVersionDetection', tr('noVersionDetection'))
],
label: tr('versionDetection'),
defaultValue: 'standardVersionDetection')
],
[
GeneratedFormTextField('apkFilterRegEx',
label: tr('filterAPKsByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
}
])
]
];
// Previous 2 variables combined into one at runtime for convenient usage
List<List<GeneratedFormItem>> get combinedAppSpecificSettingFormItems {
return [
...additionalSourceAppSpecificSettingFormItems,
...additionalAppSpecificSourceAgnosticSettingFormItems
];
}
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
return null;
}
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
return apkUrl;
}
bool canSearch = false;
Future<Map<String, String>> search(String query) {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) {
return null;
}
}
ObtainiumError getObtainiumHttpError(Response res) {
return ObtainiumError(res.reasonPhrase ??
tr('errorWithHttpStatusCode', args: [res.statusCode.toString()]));
}
abstract class MassAppUrlSource {
late String name;
late List<String> requiredArgs;
Future<Map<String, String>> getUrlsWithDescriptions(List<String> args);
}
regExValidator(String? value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return tr('invalidRegEx');
}
return null;
}
class SourceProvider {
// Add more source classes here so they are available via the service
List<AppSource> sources = [
GitHub(),
GitLab(),
Codeberg(),
FDroid(),
IzzyOnDroid(),
FDroidRepo(),
SourceForge(),
APKMirror(),
Mullvad(),
Signal(),
VLC(),
// WhatsApp(), // As of 2023-03-20 this is unusable as the version on the webpage is months out of date
TelegramApp(),
SteamMobile(),
NeutronCode(),
HTML() // This should ALWAYS be the last option as they are tried in order
];
// Add more mass url source classes here so they are available via the service
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
AppSource getSource(String url) {
url = preStandardizeUrl(url);
AppSource? source;
for (var s in sources.where((element) => element.host != null)) {
if (url.contains('://${s.host}')) {
source = s;
break;
}
}
if (source == null) {
for (var s in sources.where((element) => element.host == null)) {
try {
s.standardizeURL(url);
source = s;
break;
} catch (e) {
//
}
}
}
if (source == null) {
throw UnsupportedURLError();
}
return source;
}
bool ifRequiredAppSpecificSettingsExist(AppSource source) {
for (var row in source.combinedAppSpecificSettingFormItems) {
for (var element in row) {
if (element is GeneratedFormTextField && element.required) {
return true;
}
}
}
return false;
}
String generateTempID(
String standardUrl, Map<String, dynamic> additionalSettings) =>
(standardUrl + additionalSettings.toString()).hashCode.toString();
bool isTempId(App app) {
// return app.id == generateTempID(app.url, app.additionalSettings);
return RegExp('^[0-9]+\$').hasMatch(app.id);
}
Future<App> getApp(
AppSource source, String url, Map<String, dynamic> additionalSettings,
{App? currentApp, bool trackOnlyOverride = false}) async {
if (trackOnlyOverride || source.enforceTrackOnly) {
additionalSettings['trackOnly'] = true;
}
var trackOnly = additionalSettings['trackOnly'] == true;
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalSettings);
if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' &&
apk.releaseDate != null) {
apk.version = apk.releaseDate!.microsecondsSinceEpoch.toString();
}
if (additionalSettings['apkFilterRegEx'] != null) {
var reg = RegExp(additionalSettings['apkFilterRegEx']);
apk.apkUrls =
apk.apkUrls.where((element) => reg.hasMatch(element)).toList();
}
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
var name = currentApp?.name.trim() ??
apk.names.name[0].toUpperCase() + apk.names.name.substring(1);
return App(
currentApp?.id ??
source.tryInferringAppId(standardUrl,
additionalSettings: additionalSettings) ??
generateTempID(standardUrl, additionalSettings),
standardUrl,
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
name.trim().isNotEmpty
? name
: apk.names.name[0].toUpperCase() + apk.names.name.substring(1),
currentApp?.installedVersion,
apkVersion,
apk.apkUrls,
apk.apkUrls.length - 1 >= 0 ? apk.apkUrls.length - 1 : 0,
additionalSettings,
DateTime.now(),
currentApp?.pinned ?? false,
categories: currentApp?.categories ?? const [],
releaseDate: apk.releaseDate,
changeLog: apk.changeLog);
}
// Returns errors in [results, errors] instead of throwing them
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
{List<String> ignoreUrls = const []}) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try {
var source = getSource(url);
apps.add(await getApp(
source,
url,
getDefaultValuesFromFormItems(
source.combinedAppSpecificSettingFormItems)));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
}