Files
Obtainium/lib/app_sources/github.dart
Imran Remtulla 7ab50177f7 Fix UI unresponsiveness during update checks (#3043)
- Memoize findStandardFormatsForVersion with bounded 4096-entry cache,
  and deduplicate the 436 generated version regex patterns. This
  collapses the per-GitHub-app sort from ~500K regex evaluations to
  ~100, and caches results across all version reconciliation calls.

- Optimize GitHub _sortGitHubReleases: date sort (the default) now
  skips regex computation entirely; smartname sorts precompute formats
  once per release instead of re-evaluating per sort-comparison pair.

- Inline app list computation into AppsPage.build() instead of using
  a three-layer caching system (context.select, pipelineSignature,
  _computeListData). With memoization making rebuilds ~2ms, the
  complexity of signature hashing and cache field management is
  unnecessary.

- Cache getAppsDir() to avoid redundant platform-channel calls per
  app during saveApps batches.
2026-07-15 17:04:37 +01:00

892 lines
29 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/components/generated_form_model.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHub extends AppSource {
GitHub({bool hostChanged = false}) {
name = 'GitHub';
hosts = ['github.com'];
appIdInferIsOptional = true;
showReleaseDateAsVersionToggle = true;
this.hostChanged = hostChanged;
allowIncludeZips = true;
allowIncludeTarballs = true;
canSearch = true;
}
@override
List<GeneratedFormItem> get sourceConfigSettingFormItems => [
GeneratedFormTextField(
'github-creds',
label: tr('githubPATLabel'),
password: true,
required: false,
helpUrl:
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
),
GeneratedFormTextField(
'GHReqPrefix',
label: tr('GHReqPrefix'),
hint: 'gh-proxy.org',
required: false,
additionalValidators: [
(value) {
try {
if (value != null && Uri.parse(value).scheme.isNotEmpty) {
throw true;
}
if (value != null) {
Uri.parse('https://$value/api.github.com');
}
} catch (e) {
return tr('invalidInput');
}
return null;
},
],
helpUrl: 'https://github.com/sky22333/hubproxy',
),
GeneratedFormSwitch(
'checkRepoRename',
label: tr('repoRenamedCheck'),
value: false,
),
];
@override
List<List<GeneratedFormItem>>
get additionalSourceAppSpecificSettingFormItems => [
[
GeneratedFormSwitch(
'includePrereleases',
label: tr('includePrereleases'),
value: false,
),
],
AppSource.fallbackToOlderReleasesFormItem,
[
GeneratedFormTextField(
'filterReleaseTitlesByRegEx',
label: tr('filterReleaseTitlesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
[
GeneratedFormTextField(
'filterReleaseNotesByRegEx',
label: tr('filterReleaseNotesByRegEx'),
required: false,
additionalValidators: [
(value) {
return regExValidator(value);
},
],
),
],
[GeneratedFormSwitch('verifyLatestTag', label: tr('verifyLatestTag'))],
[
GeneratedFormDropdown(
'sortMethodChoice',
[
MapEntry('date', tr('releaseDate')),
MapEntry('smartname', tr('smartname')),
MapEntry('none', tr('none')),
MapEntry(
'smartname-datefallback',
'${tr('smartname')} x ${tr('releaseDate')}',
),
MapEntry('name', tr('name')),
],
label: tr('sortMethod'),
value: 'date',
),
],
[
GeneratedFormSwitch(
'useLatestAssetDateAsReleaseDate',
label: tr('useLatestAssetDateAsReleaseDate'),
value: false,
),
],
[
GeneratedFormSwitch(
'releaseTitleAsVersion',
label: tr('releaseTitleAsVersion'),
value: false,
),
],
];
@override
List<GeneratedFormItem> get searchQuerySettingFormItems => [
GeneratedFormTextField(
'minStarCount',
label: tr('minStarCount'),
value: '0',
additionalValidators: [
(value) {
try {
int.parse(value ?? '0');
} catch (e) {
return tr('invalidInput');
}
return null;
},
],
),
];
@override
Future<String?> tryInferringAppId(
String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
const possibleBuildGradleLocations = [
'/app/build.gradle',
'android/app/build.gradle',
'src/app/build.gradle',
];
for (var path in possibleBuildGradleLocations) {
try {
final res = await sourceRequest(
'${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/contents/$path',
additionalSettings,
);
if (res.statusCode == 200) {
try {
final body = jsonDecode(res.body);
final trimmedLines = utf8
.decode(
base64.decode(
body['content'].toString().split('\n').join(''),
),
)
.split('\n')
.map((e) => e.trim());
var appIds = trimmedLines.where(
(l) =>
l.startsWith('applicationId "') ||
l.startsWith('applicationId \''),
);
appIds = appIds.map((appId) {
final parts = appId.split(
appId.startsWith('applicationId "') ? '"' : '\'',
);
return parts.length > 1 ? parts[1] : '';
});
appIds = appIds
.map((appId) {
if (appId.startsWith('\${') && appId.endsWith('}')) {
final varLine = trimmedLines
.where(
(l) => l.startsWith(
'def ${appId.substring(2, appId.length - 1)}',
),
)
.firstOrNull;
if (varLine == null) return '';
final parts = varLine.split(
varLine.contains('"') ? '"' : '\'',
);
appId = parts.length > 1 ? parts[1] : '';
}
return appId;
})
.where((appId) => appId.isNotEmpty);
if (appIds.length == 1) {
return appIds.first;
}
} catch (err) {
unawaited(
LogsProvider().add(
'Error parsing build.gradle from ${res.request?.url.toString() ?? standardUrl}: ${err.toString()}',
),
);
}
}
} catch (err) {
unawaited(
LogsProvider().add(
'Failed to extract ID from build.gradle or APK: ${err.toString()}',
),
);
}
}
return null;
}
@override
String sourceSpecificStandardizeURL(
String url, {
bool forSelection = false,
}) => standardizeUrlWithRegex(
url,
subdomainPrefix: r'(www\.)?',
pathPattern: r'/[^/]+/[^/]+',
);
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
String url, {
bool forAPKDownload = false,
}) async {
final token = await getTokenIfAny(additionalSettings);
final headers = <String, String>{};
if (token != null && token.isNotEmpty) {
headers[HttpHeaders.authorizationHeader] = 'Token $token';
}
if (forAPKDownload == true) {
headers[HttpHeaders.acceptHeader] = 'application/octet-stream';
}
if (headers.isNotEmpty) {
return headers;
} else {
return null;
}
}
Future<String?> getTokenIfAny(Map<String, dynamic> additionalSettings) async {
final SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
final sourceConfig = await getSourceConfigValues(
additionalSettings,
settingsProvider,
);
String? creds = sourceConfig['github-creds'];
if ((additionalSettings['GHReqPrefix'] as String? ?? '').isNotEmpty) {
creds = null;
}
if (creds != null) {
final userNameEndIndex = creds.indexOf(':');
if (userNameEndIndex > 0) {
creds = creds.substring(
userNameEndIndex + 1,
); // For old username-included token inputs
}
return creds;
} else {
return null;
}
}
@override
Future<String?> getSourceNote() async {
if (!hostChanged && (await getTokenIfAny({})) == null) {
return '${tr('githubSourceNote')} ${hostChanged ? tr('addInfoBelow') : tr('addInfoInSettings')}';
}
return null;
}
@override
Future<String> generalReqPrefetchModifier(
String reqUrl,
Map<String, dynamic> additionalSettings,
) async {
if ((additionalSettings['GHReqPrefix'] as String? ?? '').isNotEmpty) {
final uri = Uri.parse(reqUrl);
return 'https://${additionalSettings['GHReqPrefix']}/${uri.toString().substring('https://'.length)}';
}
return reqUrl;
}
Future<String> getAPIHost(Map<String, dynamic> additionalSettings) async =>
'https://api.${hosts[0]}';
Future<String> convertStandardUrlToAPIUrl(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async =>
'${await getAPIHost(additionalSettings)}/repos${standardUrl.substring('https://${hosts[0]}'.length)}';
/// Checks if the repository has been renamed or transferred.
///
/// This method explicitly disables automatic redirect following to detect when
/// GitHub returns a redirect (indicating the repository has moved). A redirect
/// from the GitHub API for a repository endpoint definitively indicates that
/// the repository has been renamed or transferred to a different owner.
///
/// Throws [RepositoryRenamedError] if a redirect is detected.
Future<void> checkForRepositoryRename(
String standardUrl,
Map<String, dynamic> additionalSettings,
Map<String, String> sourceConfigSettingValues,
) async {
if (sourceConfigSettingValues['checkRepoRename'] != 'true') {
return;
}
final uri = Uri.tryParse(standardUrl);
final host = uri?.host.toLowerCase() ?? '';
// Guard against non-GitHub URLs
if (host != hosts[0] && host != 'www.${hosts[0]}') {
return;
}
final apiUrl = await convertStandardUrlToAPIUrl(
standardUrl,
additionalSettings,
);
final Response res = await sourceRequest(
apiUrl,
additionalSettings,
followRedirects: false,
);
if (res.statusCode >= 300 && res.statusCode < 400) {
final String? location =
res.headers[HttpHeaders.locationHeader.toLowerCase()];
if (location != null) {
final Response res2 = await sourceRequest(
location,
additionalSettings,
followRedirects: false,
);
String? newUrl;
try {
newUrl = jsonDecode(res2.body)['html_url'];
} catch (e) {
unawaited(
LogsProvider().add(
'Failed to parse redirect response for repo rename: ${e.toString()}',
),
);
}
if (newUrl != null) {
throw RepositoryRenamedError(standardUrl, newUrl);
}
}
}
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases';
List<dynamic> _findReleaseAssetUrls(
dynamic release,
bool includeZips,
bool includeTarballs,
Map<String, String> sourceConfigSettingValues,
) =>
(release['assets'] as List<dynamic>?)?.map((e) {
final name = e['name'].toString();
var url =
!AppSource.isApkOrContainerFile(
name,
includeArchives: includeZips,
includeTarballs: includeTarballs,
)
? (e['browser_download_url'] ?? e['url'])
: (e['url'] ?? e['browser_download_url']);
url = undoGHProxyMod(url, sourceConfigSettingValues);
e['final_url'] = (e['name'] != null) && (url != null)
? MapEntry(e['name'] as String, url as String)
: const MapEntry('', '');
return e;
}).toList() ??
[];
DateTime? _getPublishDateFromRelease(dynamic rel) {
final pub = rel?['published_at'];
if (pub is String) return DateTime.tryParse(pub);
final commitCreated = rel?['commit']?['created'];
if (commitCreated is String) return DateTime.tryParse(commitCreated);
return null;
}
DateTime? _getNewestAssetDateFromRelease(dynamic rel) {
final allAssets = rel['assets'] as List<dynamic>?;
final filteredAssets = rel['filteredAssets'] as List<dynamic>?;
final t = (filteredAssets ?? allAssets)
?.map((e) {
final updated = e?['updated_at'];
return updated is String ? DateTime.tryParse(updated) : null;
})
.where((e) => e != null)
.toList();
t?.sort((a, b) => b!.compareTo(a!));
if (t?.isNotEmpty == true) {
return t!.first;
}
return null;
}
DateTime? _getReleaseDateFromRelease(dynamic rel, bool useAssetDate) =>
!useAssetDate
? _getPublishDateFromRelease(rel)
: _getNewestAssetDateFromRelease(rel);
void _sortGitHubReleases(
List<dynamic> releases,
String sortMethod,
bool useLatestAssetDateAsReleaseDate,
) {
if (sortMethod == 'none') return;
// Precompute dates and (for smartname/name sorts) per-release format
// sets once. Memoization in findStandardFormatsForVersion already handles
// the per-version cache; we still precompute here so the sort comparator
// only performs O(1) lookups instead of O(n) per comparison.
final isDateOnly = sortMethod == 'date';
final Map<dynamic, DateTime?> dates = {};
final Map<dynamic, Set<String>> formats = {};
if (!isDateOnly) {
for (final r in releases) {
if (r == null) continue;
final name = (r['tag_name'] ?? r['name'])?.toString() ?? '';
formats[r] = findStandardFormatsForVersion(name, false);
}
}
releases.sort((a, b) {
if (a == null) return -1;
if (b == null) return 1;
if (isDateOnly) {
final dateA = dates.putIfAbsent(a, () => _getReleaseDateFromRelease(a, useLatestAssetDateAsReleaseDate));
final dateB = dates.putIfAbsent(b, () => _getReleaseDateFromRelease(b, useLatestAssetDateAsReleaseDate));
return (dateA ?? DateTime(1)).compareTo(dateB ?? DateTime(0));
}
final nameA = a['tag_name'] ?? a['name'];
final nameB = b['tag_name'] ?? b['name'];
final stdFormats = formats[a]!.intersection(formats[b]!);
if (sortMethod == 'smartname-datefallback' && stdFormats.isEmpty) {
final dateA = _getReleaseDateFromRelease(a, useLatestAssetDateAsReleaseDate);
final dateB = _getReleaseDateFromRelease(b, useLatestAssetDateAsReleaseDate);
return (dateA ?? DateTime(1)).compareTo(dateB ?? DateTime(0));
}
if (sortMethod != 'name' && stdFormats.isNotEmpty) {
final sortedFormats = stdFormats.toList()
..sort((x, y) => y.length.compareTo(x.length));
final reg = RegExp(sortedFormats.first);
final matchA = reg.firstMatch(nameA);
final matchB = reg.firstMatch(nameB);
if (matchA == null || matchB == null) {
return compareAlphaNumeric(nameA as String, nameB as String);
}
return compareAlphaNumeric(
(nameA as String).substring(matchA.start, matchA.end),
(nameB as String).substring(matchB.start, matchB.end),
);
}
return compareAlphaNumeric(nameA as String, nameB as String);
});
}
void _positionLatestRelease(List<dynamic> releases, dynamic latestRelease) {
if (latestRelease == null ||
(latestRelease['tag_name'] ?? latestRelease['name']) == null ||
releases.isEmpty ||
(latestRelease['tag_name'] ?? latestRelease['name']) ==
(releases[releases.length - 1]['tag_name'] ??
releases[releases.length - 1]['name'])) {
return;
}
final ind = releases.indexWhere(
(element) =>
(latestRelease['tag_name'] ?? latestRelease['name']) ==
(element['tag_name'] ?? element['name']),
);
if (ind >= 0) {
releases.add(releases.removeAt(ind));
}
}
dynamic _selectGitHubTargetRelease({
required List<dynamic> releases,
required bool fallbackToOlderReleases,
required bool includePrereleases,
required String? regexFilter,
required String? regexNotesFilter,
required bool includeZips,
required bool includeTarballs,
required Map<String, dynamic> additionalSettings,
required Map<String, String> sourceConfigSettingValues,
}) {
var prereleaseSkipped = 0;
for (int i = 0; i < releases.length; i++) {
if (!fallbackToOlderReleases && i > prereleaseSkipped) break;
if (!includePrereleases && releases[i]['prerelease'] == true) {
prereleaseSkipped++;
continue;
}
if (releases[i]['draft'] == true) {
continue;
}
var nameToFilter = releases[i]['name'] as String?;
if (nameToFilter == null || nameToFilter.trim().isEmpty) {
nameToFilter = releases[i]['tag_name']?.toString() ?? '';
}
if (regexFilter != null &&
!RegExp(regexFilter).hasMatch(nameToFilter.trim())) {
continue;
}
if (regexNotesFilter != null &&
!RegExp(
regexNotesFilter,
).hasMatch(((releases[i]['body'] as String?) ?? '').trim())) {
continue;
}
final allAssetsWithUrls = _findReleaseAssetUrls(
releases[i],
includeZips,
includeTarballs,
sourceConfigSettingValues,
);
final List<MapEntry<String, String>> allAssetUrls = allAssetsWithUrls
.map((e) => e['final_url'] as MapEntry<String, String>)
.toList();
final apkAssetsWithUrls = allAssetsWithUrls.where((element) {
final name = (element['final_url'] as MapEntry<String, String>).key;
return AppSource.isApkOrContainerFile(
name,
includeArchives: includeZips,
includeTarballs: includeTarballs,
);
}).toList();
final filteredApkUrls = filterApks(
apkAssetsWithUrls
.map((e) => e['final_url'] as MapEntry<String, String>)
.toList(),
additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter'],
);
final filteredApks = apkAssetsWithUrls
.where(
(e) => filteredApkUrls
.where(
(e2) =>
e2.key ==
(e['final_url'] as MapEntry<String, String>).key,
)
.isNotEmpty,
)
.toList();
if (filteredApks.isEmpty && additionalSettings['trackOnly'] != true) {
continue;
}
final targetRelease = releases[i];
targetRelease['apkUrls'] = filteredApkUrls;
targetRelease['filteredAssets'] = filteredApks;
targetRelease['version'] =
additionalSettings['releaseTitleAsVersion'] == true
? nameToFilter
: targetRelease['tag_name'] ?? targetRelease['name'];
if (targetRelease['tarball_url'] != null) {
allAssetUrls.add(
MapEntry(
(targetRelease['version'] ?? 'source') + '.tar.gz',
undoGHProxyMod(
targetRelease['tarball_url'],
sourceConfigSettingValues,
),
),
);
}
if (targetRelease['zipball_url'] != null) {
allAssetUrls.add(
MapEntry(
(targetRelease['version'] ?? 'source') + '.zip',
undoGHProxyMod(
targetRelease['zipball_url'],
sourceConfigSettingValues,
),
),
);
}
targetRelease['allAssetUrls'] = allAssetUrls;
return targetRelease;
}
return null;
}
/// Fetches and parses GitHub releases, applying sort/filter/prelease settings,
/// then resolves the best matching release to an [APKDetails] result.
Future<APKDetails> _fetchReleaseDetails(
String requestUrl,
String standardUrl,
Map<String, dynamic> additionalSettings, {
Function(Response)? onHttpErrorCode,
}) async {
final SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
final sourceConfigSettingValues = await getSourceConfigValues(
additionalSettings,
settingsProvider,
);
await checkForRepositoryRename(
standardUrl,
additionalSettings,
sourceConfigSettingValues,
);
final bool includePrereleases =
additionalSettings['includePrereleases'] == true;
final bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
final String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseTitlesByRegEx']
: null;
final String? regexNotesFilter =
(additionalSettings['filterReleaseNotesByRegEx'] as String?)
?.isNotEmpty ==
true
? additionalSettings['filterReleaseNotesByRegEx']
: null;
final bool verifyLatestTag = additionalSettings['verifyLatestTag'] == true;
final bool useLatestAssetDateAsReleaseDate =
additionalSettings['useLatestAssetDateAsReleaseDate'] == true;
final String sortMethod =
additionalSettings['sortMethodChoice'] ?? 'smartname-datefallback';
final bool includeZips = additionalSettings['includeZips'] == true;
final bool includeTarballs = additionalSettings['includeTarballs'] == true;
dynamic latestRelease;
if (verifyLatestTag) {
final uri = Uri.parse(requestUrl);
final latestUrl = uri.replace(query: null, path: '${uri.path}/latest');
final Response res = await sourceRequest(
latestUrl.toString(),
additionalSettings,
);
if (res.statusCode != 200) {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
}
throw getObtainiumHttpError(res);
}
latestRelease = jsonDecode(res.body);
}
final Response res = await sourceRequest(requestUrl, additionalSettings);
if (res.statusCode == 200) {
final decoded = jsonDecode(res.body);
if (decoded is! List) {
throw NoReleasesError();
}
var releases = decoded;
if (latestRelease != null) {
final latestTag = latestRelease['tag_name'] ?? latestRelease['name'];
if (releases
.where(
(element) =>
(element['tag_name'] ?? element['name']) == latestTag,
)
.isEmpty) {
releases = [latestRelease, ...releases];
}
}
if (sortMethod == 'none') {
releases = releases.reversed.toList();
} else {
_sortGitHubReleases(
releases,
sortMethod,
useLatestAssetDateAsReleaseDate,
);
}
_positionLatestRelease(releases, latestRelease);
releases = releases.reversed.toList();
final targetRelease = _selectGitHubTargetRelease(
releases: releases,
fallbackToOlderReleases: fallbackToOlderReleases,
includePrereleases: includePrereleases,
regexFilter: regexFilter,
regexNotesFilter: regexNotesFilter,
includeZips: includeZips,
includeTarballs: includeTarballs,
additionalSettings: additionalSettings,
sourceConfigSettingValues: sourceConfigSettingValues,
);
if (targetRelease == null) {
throw NoReleasesError();
}
final String? version = targetRelease['version'];
final DateTime? releaseDate = _getReleaseDateFromRelease(
targetRelease,
useLatestAssetDateAsReleaseDate,
);
if (version == null || version.isEmpty) {
throw NoVersionError();
}
final changeLog = (targetRelease['body'] ?? '').toString();
return APKDetails(
version,
targetRelease['apkUrls'] as List<MapEntry<String, String>>,
getAppNames(standardUrl),
releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog,
allAssetUrls:
targetRelease['allAssetUrls'] as List<MapEntry<String, String>>,
);
} else {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
}
throw getObtainiumHttpError(res);
}
}
Future<APKDetails> fetchReleaseDetailsWithTagFallback(
String standardUrl,
Map<String, dynamic> additionalSettings,
Future<String> Function(bool) reqUrlGenerator,
dynamic Function(Response)? onHttpErrorCode,
) async {
try {
return await _fetchReleaseDetails(
await reqUrlGenerator(false),
standardUrl,
additionalSettings,
onHttpErrorCode: onHttpErrorCode,
);
} catch (err) {
if (err is NoReleasesError && additionalSettings['trackOnly'] == true) {
return await _fetchReleaseDetails(
await reqUrlGenerator(true),
standardUrl,
additionalSettings,
onHttpErrorCode: onHttpErrorCode,
);
} else {
rethrowOrWrapError(err);
}
}
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
try {
return await fetchReleaseDetailsWithTagFallback(
standardUrl,
additionalSettings,
(bool useTagUrl) async {
return '${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100';
},
(Response res) {
rateLimitErrorCheck(res);
},
);
} catch (e) {
rethrowOrWrapError(e);
}
}
AppNames getAppNames(String standardUrl) {
final String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
final pathStart = temp.indexOf('/');
if (pathStart < 0) throw InvalidURLError(name);
final List<String> names = temp.substring(pathStart + 1).split('/');
if (names.isEmpty || names[0].isEmpty) throw InvalidURLError(name);
return AppNames(names[0], names.sublist(1).join('/'));
}
Future<Map<String, List<String>>> searchCommon(
String query,
String requestUrl,
String rootProp, {
Function(Response)? onHttpErrorCode,
Map<String, dynamic> querySettings = const {},
}) async {
final Response res = await sourceRequest(requestUrl, {});
if (res.statusCode == 200) {
final int minStarCount =
int.tryParse(querySettings['minStarCount']?.toString() ?? '') ?? 0;
final Map<String, List<String>> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body)[rootProp] as List<dynamic>)) {
if ((e['stargazers_count'] ?? e['stars_count'] ?? 0) >= minStarCount) {
urlsWithDescriptions.addAll({
e['html_url'] as String: [
e['full_name'] as String,
((e['archived'] == true ? '[ARCHIVED] ' : '') +
(e['description'] != null
? e['description'] as String
: tr('noDescription'))),
],
});
}
}
return urlsWithDescriptions;
} else {
if (onHttpErrorCode != null) {
onHttpErrorCode(res);
}
throw getObtainiumHttpError(res);
}
}
String undoGHProxyMod(
String reqUrl,
Map<String, String> sourceConfigSettingValues,
) {
final ghReqPrefix = sourceConfigSettingValues['GHReqPrefix'];
if (ghReqPrefix == null || ghReqPrefix.isEmpty) return reqUrl;
final prefix = 'https://$ghReqPrefix/';
return reqUrl.startsWith(prefix) ? reqUrl.substring(prefix.length) : reqUrl;
}
@override
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
final sp = SettingsProvider();
await sp.initializeSettings();
final sourceConfigSettingValues = await getSourceConfigValues({}, sp);
final results = await searchCommon(
query,
'${await getAPIHost({})}/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100',
'items',
onHttpErrorCode: (Response res) {
rateLimitErrorCheck(res);
},
querySettings: querySettings,
);
if ((sourceConfigSettingValues['GHReqPrefix'] ?? '').isNotEmpty) {
final Map<String, List<String>> results2 = {};
results.forEach((k, v) {
results2[undoGHProxyMod(k, sourceConfigSettingValues)] = v;
});
return results2;
} else {
return results;
}
}
void rateLimitErrorCheck(Response res) {
if (res.headers['x-ratelimit-remaining'] == '0') {
final now = DateTime.now();
final resetEpochSeconds =
int.tryParse(res.headers['x-ratelimit-reset'] ?? '') ??
now.millisecondsSinceEpoch ~/ 1000 + 3600;
final nowSeconds = now.millisecondsSinceEpoch ~/ 1000;
final remainingMinutes = ((resetEpochSeconds - nowSeconds) / 60)
.ceil()
.clamp(0, 9999);
throw RateLimitError(remainingMinutes);
}
}
}