import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:html/parser.dart'; import 'package:http/http.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, {bool forSelection = false}) { 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.where((s) => s.trim().isNotEmpty).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.body.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, )).body; } } } 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.body).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.body); 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); } } }