import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:html/parser.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/providers/source_provider.dart'; class FDroid extends AppSource { FDroid() { hosts = ['f-droid.org']; name = tr('fdroid'); naiveStandardVersionDetection = true; canSearch = true; additionalSourceAppSpecificSettingFormItems = [ [ GeneratedFormTextField('filterVersionsByRegEx', label: tr('filterVersionsByRegEx'), required: false, additionalValidators: [ (value) { return regExValidator(value); } ]) ], [ GeneratedFormSwitch('trySelectingSuggestedVersionCode', label: tr('trySelectingSuggestedVersionCode')) ], [ GeneratedFormSwitch('autoSelectHighestVersionCode', label: tr('autoSelectHighestVersionCode')) ], ]; } @override String sourceSpecificStandardizeURL(String url) { RegExp standardUrlRegExB = RegExp( '^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+packages/+[^/]+', caseSensitive: false); RegExpMatch? match = standardUrlRegExB.firstMatch(url); if (match != null) { url = 'https://${Uri.parse(match.group(0)!).host}/packages/${Uri.parse(url).pathSegments.last}'; } RegExp standardUrlRegExA = RegExp( '^https?://(www\\.)?${getSourceRegex(hosts)}/+packages/+[^/]+', caseSensitive: false); match = standardUrlRegExA.firstMatch(url); if (match == null) { throw InvalidURLError(name); } return match.group(0)!; } @override Future tryInferringAppId(String standardUrl, {Map additionalSettings = const {}}) async { return Uri.parse(standardUrl).pathSegments.last; } @override Future getLatestAPKDetails( String standardUrl, Map additionalSettings, ) async { String? appId = await tryInferringAppId(standardUrl); String host = Uri.parse(standardUrl).host; var details = getAPKUrlsFromFDroidPackagesAPIResponse( await sourceRequest( 'https://$host/api/v1/packages/$appId', additionalSettings), 'https://$host/repo/$appId', standardUrl, name, additionalSettings: additionalSettings); if (!hostChanged) { try { var res = await sourceRequest( 'https://gitlab.com/fdroid/fdroiddata/-/raw/master/metadata/$appId.yml', additionalSettings); var lines = res.data.split('\n'); var authorLines = lines.where((l) => l.startsWith('AuthorName: ')); if (authorLines.isNotEmpty) { details.names.author = authorLines.first.split(': ').sublist(1).join(': '); } var changelogUrls = lines .where((l) => l.startsWith('Changelog: ')) .map((e) => e.split(' ').sublist(1).join(' ')); if (changelogUrls.isNotEmpty) { details.changeLog = changelogUrls.first; bool isGitHub = false; bool isGitLab = false; try { GitHub().sourceSpecificStandardizeURL(details.changeLog!); isGitHub = true; } catch (e) { // } try { GitLab().sourceSpecificStandardizeURL(details.changeLog!); isGitLab = true; } catch (e) { // } if ((isGitHub || isGitLab) && (details.changeLog?.indexOf('/blob/') ?? -1) >= 0) { details.changeLog = (await sourceRequest( details.changeLog!.replaceFirst('/blob/', '/raw/'), additionalSettings)) .data; } } } catch (e) { // Fail silently } if ((details.changeLog?.length ?? 0) > 2048) { details.changeLog = '${details.changeLog!.substring(0, 2048)}...'; } } return details; } @override Future>> search(String query, {Map querySettings = const {}}) async { Response res = await sourceRequest( 'https://search.${hosts[0]}/?q=${Uri.encodeQueryComponent(query)}', {}); if (res.statusCode == 200) { Map> urlsWithDescriptions = {}; parse(res.data).querySelectorAll('.package-header').forEach((e) { String? url = e.attributes['href']; if (url != null) { try { standardizeUrl(url); } catch (e) { url = null; } } if (url != null) { urlsWithDescriptions[url] = [ e.querySelector('.package-name')?.text.trim() ?? '', e.querySelector('.package-summary')?.text.trim() ?? tr('noDescription') ]; } }); return urlsWithDescriptions; } else { throw getObtainiumHttpError(res); } } APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( Response res, String apkUrlPrefix, String standardUrl, String sourceName, {Map additionalSettings = const {}}) { var autoSelectHighestVersionCode = additionalSettings['autoSelectHighestVersionCode'] == true; var trySelectingSuggestedVersionCode = additionalSettings['trySelectingSuggestedVersionCode'] == true; var filterVersionsByRegEx = (additionalSettings['filterVersionsByRegEx'] as String?)?.isNotEmpty == true ? additionalSettings['filterVersionsByRegEx'] : null; var apkFilterRegEx = (additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == true ? additionalSettings['apkFilterRegEx'] : null; if (res.statusCode == 200) { var response = jsonDecode(res.data); List releases = response['packages'] ?? []; if (apkFilterRegEx != null) { releases = releases.where((rel) { String apk = '${apkUrlPrefix}_${rel['versionCode']}.apk'; return filterApks([MapEntry(apk, apk)], apkFilterRegEx, false) .isNotEmpty; }).toList(); } if (releases.isEmpty) { throw NoReleasesError(); } String? version; Iterable releaseChoices = []; // Grab the versionCode suggested if the user chose to do that // Only do so at this stage if the user has no release filter if (trySelectingSuggestedVersionCode && response['suggestedVersionCode'] != null && filterVersionsByRegEx == null) { var suggestedReleases = releases.where((element) => element['versionCode'] == response['suggestedVersionCode']); if (suggestedReleases.isNotEmpty) { releaseChoices = suggestedReleases; version = suggestedReleases.first['versionName']; } } // Apply the release filter if any if (filterVersionsByRegEx?.isNotEmpty == true) { version = null; releaseChoices = []; for (var i = 0; i < releases.length; i++) { if (RegExp(filterVersionsByRegEx!) .hasMatch(releases[i]['versionName'])) { version = releases[i]['versionName']; } } if (version == null) { throw NoVersionError(); } } // Default to the highest version version ??= releases[0]['versionName']; if (version == null) { throw NoVersionError(); } // If a suggested release was not already picked, pick all those with the selected version if (releaseChoices.isEmpty) { releaseChoices = releases.where((element) => element['versionName'] == version); } // For the remaining releases, use the toggles to auto-select one if possible if (releaseChoices.length > 1) { if (autoSelectHighestVersionCode) { releaseChoices = [releaseChoices.first]; } else if (trySelectingSuggestedVersionCode && response['suggestedVersionCode'] != null) { var suggestedReleases = releaseChoices.where((element) => element['versionCode'] == response['suggestedVersionCode']); if (suggestedReleases.isNotEmpty) { releaseChoices = suggestedReleases; } } } if (releaseChoices.isEmpty) { throw NoReleasesError(); } List apkUrls = releaseChoices .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') .toList(); return APKDetails(version, getApkUrlsFromUrls(apkUrls.toSet().toList()), AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last)); } else { throw getObtainiumHttpError(res); } } }