From 224e435bbb219f64fd521feb95d08f15e36c81cb Mon Sep 17 00:00:00 2001 From: Imran Remtulla Date: Thu, 22 Sep 2022 19:35:15 -0400 Subject: [PATCH] Moved App Sources into separate files --- lib/app_sources/fdroid.dart | 49 ++++ lib/app_sources/github.dart | 70 ++++++ lib/app_sources/gitlab.dart | 63 +++++ lib/app_sources/izzyondroid.dart | 57 +++++ lib/app_sources/mullvad.dart | 43 ++++ lib/app_sources/signal.dart | 36 +++ lib/main.dart | 1 + lib/mass_app_sources/githubstars.dart | 32 +++ lib/pages/app.dart | 1 - lib/providers/source_provider.dart | 347 +------------------------- 10 files changed, 364 insertions(+), 335 deletions(-) create mode 100644 lib/app_sources/fdroid.dart create mode 100644 lib/app_sources/github.dart create mode 100644 lib/app_sources/gitlab.dart create mode 100644 lib/app_sources/izzyondroid.dart create mode 100644 lib/app_sources/mullvad.dart create mode 100644 lib/app_sources/signal.dart create mode 100644 lib/mass_app_sources/githubstars.dart diff --git a/lib/app_sources/fdroid.dart b/lib/app_sources/fdroid.dart new file mode 100644 index 0000000..629454b --- /dev/null +++ b/lib/app_sources/fdroid.dart @@ -0,0 +1,49 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class FDroid implements AppSource { + @override + late String host = 'f-droid.org'; + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw notValidURL; + } + return url.substring(0, match.end); + } + + @override + Future getLatestAPKDetails(String standardUrl) async { + Response res = await get(Uri.parse(standardUrl)); + if (res.statusCode == 200) { + var latestReleaseDiv = + parse(res.body).querySelector('#latest.package-version'); + var apkUrl = latestReleaseDiv + ?.querySelector('.package-version-download a') + ?.attributes['href']; + if (apkUrl == null) { + throw noAPKFound; + } + var version = latestReleaseDiv + ?.querySelector('.package-version-header b') + ?.innerHtml + .split(' ') + .last; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails(version, [apkUrl]); + } else { + throw couldNotFindReleases; + } + } + + @override + AppNames getAppNames(String standardUrl) { + return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); + } +} diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart new file mode 100644 index 0000000..f77f3f2 --- /dev/null +++ b/lib/app_sources/github.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'package:http/http.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class GitHub implements AppSource { + @override + late String host = 'github.com'; + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw notValidURL; + } + return url.substring(0, match.end); + } + + @override + Future getLatestAPKDetails(String standardUrl) async { + Response res = await get(Uri.parse( + 'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); + if (res.statusCode == 200) { + var releases = jsonDecode(res.body) as List; + // Right now, the latest non-prerelease version is picked + // If none exists, the latest prerelease version is picked + // In the future, the user could be given a choice + var nonPrereleaseReleases = + releases.where((element) => element['prerelease'] != true).toList(); + var latestRelease = nonPrereleaseReleases.isNotEmpty + ? nonPrereleaseReleases[0] + : releases.isNotEmpty + ? releases[0] + : null; + if (latestRelease == null) { + throw couldNotFindReleases; + } + List? assets = latestRelease['assets']; + List? apkUrlList = assets + ?.map((e) { + return e['browser_download_url'] != null + ? e['browser_download_url'] as String + : ''; + }) + .where((element) => element.toLowerCase().endsWith('.apk')) + .toList(); + if (apkUrlList == null || apkUrlList.isEmpty) { + throw noAPKFound; + } + String? version = latestRelease['tag_name']; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails(version, apkUrlList); + } else { + if (res.headers['x-ratelimit-remaining'] == '0') { + throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; + } + + throw couldNotFindReleases; + } + } + + @override + AppNames getAppNames(String standardUrl) { + String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); + List names = temp.substring(temp.indexOf('/') + 1).split('/'); + return AppNames(names[0], names[1]); + } +} diff --git a/lib/app_sources/gitlab.dart b/lib/app_sources/gitlab.dart new file mode 100644 index 0000000..618a130 --- /dev/null +++ b/lib/app_sources/gitlab.dart @@ -0,0 +1,63 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/app_sources/github.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class GitLab implements AppSource { + @override + late String host = 'gitlab.com'; + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw notValidURL; + } + return url.substring(0, match.end); + } + + @override + Future getLatestAPKDetails(String standardUrl) async { + Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); + if (res.statusCode == 200) { + var standardUri = Uri.parse(standardUrl); + var parsedHtml = parse(res.body); + var entry = parsedHtml.querySelector('entry'); + var entryContent = + parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); + var apkUrlList = [ + ...getLinksFromParsedHTML( + entryContent, + RegExp( + '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', + caseSensitive: false), + standardUri.origin), + // GitLab releases may contain links to externally hosted APKs + ...getLinksFromParsedHTML(entryContent, + RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') + .where((element) => Uri.parse(element).host != '') + .toList() + ]; + if (apkUrlList.isEmpty) { + throw noAPKFound; + } + + var entryId = entry?.querySelector('id')?.innerHtml; + var version = + entryId == null ? null : Uri.parse(entryId).pathSegments.last; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails(version, apkUrlList); + } else { + throw couldNotFindReleases; + } + } + + @override + AppNames getAppNames(String standardUrl) { + // Same as GitHub + return GitHub().getAppNames(standardUrl); + } +} diff --git a/lib/app_sources/izzyondroid.dart b/lib/app_sources/izzyondroid.dart new file mode 100644 index 0000000..6a2f891 --- /dev/null +++ b/lib/app_sources/izzyondroid.dart @@ -0,0 +1,57 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class IzzyOnDroid implements AppSource { + @override + late String host = 'android.izzysoft.de'; + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw notValidURL; + } + return url.substring(0, match.end); + } + + @override + Future getLatestAPKDetails(String standardUrl) async { + Response res = await get(Uri.parse(standardUrl)); + if (res.statusCode == 200) { + var parsedHtml = parse(res.body); + var multipleVersionApkUrls = parsedHtml + .querySelectorAll('a') + .where((element) => + element.attributes['href']?.toLowerCase().endsWith('.apk') ?? + false) + .map((e) => 'https://$host${e.attributes['href'] ?? ''}') + .toList(); + if (multipleVersionApkUrls.isEmpty) { + throw noAPKFound; + } + var version = parsedHtml + .querySelector('#keydata') + ?.querySelectorAll('b') + .where( + (element) => element.innerHtml.toLowerCase().contains('version')) + .toList()[0] + .parentNode + ?.parentNode + ?.children[1] + .innerHtml; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails(version, [multipleVersionApkUrls[0]]); + } else { + throw couldNotFindReleases; + } + } + + @override + AppNames getAppNames(String standardUrl) { + return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); + } +} diff --git a/lib/app_sources/mullvad.dart b/lib/app_sources/mullvad.dart new file mode 100644 index 0000000..0d93b5d --- /dev/null +++ b/lib/app_sources/mullvad.dart @@ -0,0 +1,43 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class Mullvad implements AppSource { + @override + late String host = 'mullvad.net'; + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw notValidURL; + } + return url.substring(0, match.end); + } + + @override + Future getLatestAPKDetails(String standardUrl) async { + Response res = await get(Uri.parse('$standardUrl/en/download/android')); + if (res.statusCode == 200) { + var version = parse(res.body) + .querySelector('p.subtitle.is-6') + ?.querySelector('a') + ?.attributes['href'] + ?.split('/') + .last; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails( + version, ['https://mullvad.net/download/app/apk/latest']); + } else { + throw couldNotFindReleases; + } + } + + @override + AppNames getAppNames(String standardUrl) { + return AppNames('Mullvad-VPN', 'Mullvad-VPN'); + } +} diff --git a/lib/app_sources/signal.dart b/lib/app_sources/signal.dart new file mode 100644 index 0000000..d6ae8ee --- /dev/null +++ b/lib/app_sources/signal.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'package:http/http.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class Signal implements AppSource { + @override + late String host = 'signal.org'; + + @override + String standardizeURL(String url) { + return 'https://$host'; + } + + @override + Future getLatestAPKDetails(String standardUrl) async { + Response res = + await get(Uri.parse('https://updates.$host/android/latest.json')); + if (res.statusCode == 200) { + var json = jsonDecode(res.body); + String? apkUrl = json['url']; + if (apkUrl == null) { + throw noAPKFound; + } + String? version = json['versionName']; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails(version, [apkUrl]); + } else { + throw couldNotFindReleases; + } + } + + @override + AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); +} diff --git a/lib/main.dart b/lib/main.dart index 128efe5..9a50042 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/pages/home.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart'; diff --git a/lib/mass_app_sources/githubstars.dart b/lib/mass_app_sources/githubstars.dart new file mode 100644 index 0000000..0575d2f --- /dev/null +++ b/lib/mass_app_sources/githubstars.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class GitHubStars implements MassAppSource { + @override + late String name = 'GitHub Starred Repos'; + + @override + late List requiredArgs = ['Username']; + + @override + Future> getUrls(List args) async { + if (args.length != requiredArgs.length) { + throw 'Wrong number of arguments provided'; + } + Response res = + await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); + if (res.statusCode == 200) { + return (jsonDecode(res.body) as List) + .map((e) => e['html_url'] as String) + .toList(); + } else { + if (res.headers['x-ratelimit-remaining'] == '0') { + throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; + } + + throw 'Unable to find user\'s starred repos'; + } + } +} diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 4e02b4a..e65b1e0 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 83e1d70..6c0c3c5 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -4,8 +4,13 @@ import 'dart:convert'; import 'package:html/dom.dart'; -import 'package:http/http.dart'; -import 'package:html/parser.dart'; +import 'package:obtainium/app_sources/fdroid.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/mullvad.dart'; +import 'package:obtainium/app_sources/signal.dart'; +import 'package:obtainium/mass_app_sources/githubstars.dart'; class AppNames { late String author; @@ -95,306 +100,14 @@ abstract class AppSource { AppNames getAppNames(String standardUrl); } -class GitHub implements AppSource { - @override - late String host = 'github.com'; - - @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); - if (match == null) { - throw notValidURL; - } - return url.substring(0, match.end); - } - - @override - Future getLatestAPKDetails(String standardUrl) async { - Response res = await get(Uri.parse( - 'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); - if (res.statusCode == 200) { - var releases = jsonDecode(res.body) as List; - // Right now, the latest non-prerelease version is picked - // If none exists, the latest prerelease version is picked - // In the future, the user could be given a choice - var nonPrereleaseReleases = - releases.where((element) => element['prerelease'] != true).toList(); - var latestRelease = nonPrereleaseReleases.isNotEmpty - ? nonPrereleaseReleases[0] - : releases.isNotEmpty - ? releases[0] - : null; - if (latestRelease == null) { - throw couldNotFindReleases; - } - List? assets = latestRelease['assets']; - List? apkUrlList = assets - ?.map((e) { - return e['browser_download_url'] != null - ? e['browser_download_url'] as String - : ''; - }) - .where((element) => element.toLowerCase().endsWith('.apk')) - .toList(); - if (apkUrlList == null || apkUrlList.isEmpty) { - throw noAPKFound; - } - String? version = latestRelease['tag_name']; - if (version == null) { - throw couldNotFindLatestVersion; - } - return APKDetails(version, apkUrlList); - } else { - if (res.headers['x-ratelimit-remaining'] == '0') { - throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; - } - - throw couldNotFindReleases; - } - } - - @override - AppNames getAppNames(String standardUrl) { - String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); - List names = temp.substring(temp.indexOf('/') + 1).split('/'); - return AppNames(names[0], names[1]); - } -} - -class GitLab implements AppSource { - @override - late String host = 'gitlab.com'; - - @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); - if (match == null) { - throw notValidURL; - } - return url.substring(0, match.end); - } - - @override - Future getLatestAPKDetails(String standardUrl) async { - Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); - if (res.statusCode == 200) { - var standardUri = Uri.parse(standardUrl); - var parsedHtml = parse(res.body); - var entry = parsedHtml.querySelector('entry'); - var entryContent = - parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); - var apkUrlList = [ - ...getLinksFromParsedHTML( - entryContent, - RegExp( - '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', - caseSensitive: false), - standardUri.origin), - // GitLab releases may contain links to externally hosted APKs - ...getLinksFromParsedHTML(entryContent, - RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') - .where((element) => Uri.parse(element).host != '') - .toList() - ]; - if (apkUrlList.isEmpty) { - throw noAPKFound; - } - - var entryId = entry?.querySelector('id')?.innerHtml; - var version = - entryId == null ? null : Uri.parse(entryId).pathSegments.last; - if (version == null) { - throw couldNotFindLatestVersion; - } - return APKDetails(version, apkUrlList); - } else { - throw couldNotFindReleases; - } - } - - @override - AppNames getAppNames(String standardUrl) { - // Same as GitHub - return GitHub().getAppNames(standardUrl); - } -} - -class Signal implements AppSource { - @override - late String host = 'signal.org'; - - @override - String standardizeURL(String url) { - return 'https://$host'; - } - - @override - Future getLatestAPKDetails(String standardUrl) async { - Response res = - await get(Uri.parse('https://updates.$host/android/latest.json')); - if (res.statusCode == 200) { - var json = jsonDecode(res.body); - String? apkUrl = json['url']; - if (apkUrl == null) { - throw noAPKFound; - } - String? version = json['versionName']; - if (version == null) { - throw couldNotFindLatestVersion; - } - return APKDetails(version, [apkUrl]); - } else { - throw couldNotFindReleases; - } - } - - @override - AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); -} - -class FDroid implements AppSource { - @override - late String host = 'f-droid.org'; - - @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); - if (match == null) { - throw notValidURL; - } - return url.substring(0, match.end); - } - - @override - Future getLatestAPKDetails(String standardUrl) async { - Response res = await get(Uri.parse(standardUrl)); - if (res.statusCode == 200) { - var latestReleaseDiv = - parse(res.body).querySelector('#latest.package-version'); - var apkUrl = latestReleaseDiv - ?.querySelector('.package-version-download a') - ?.attributes['href']; - if (apkUrl == null) { - throw noAPKFound; - } - var version = latestReleaseDiv - ?.querySelector('.package-version-header b') - ?.innerHtml - .split(' ') - .last; - if (version == null) { - throw couldNotFindLatestVersion; - } - return APKDetails(version, [apkUrl]); - } else { - throw couldNotFindReleases; - } - } - - @override - AppNames getAppNames(String standardUrl) { - return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); - } -} - -class Mullvad implements AppSource { - @override - late String host = 'mullvad.net'; - - @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); - if (match == null) { - throw notValidURL; - } - return url.substring(0, match.end); - } - - @override - Future getLatestAPKDetails(String standardUrl) async { - Response res = await get(Uri.parse('$standardUrl/en/download/android')); - if (res.statusCode == 200) { - var version = parse(res.body) - .querySelector('p.subtitle.is-6') - ?.querySelector('a') - ?.attributes['href'] - ?.split('/') - .last; - if (version == null) { - throw couldNotFindLatestVersion; - } - return APKDetails( - version, ['https://mullvad.net/download/app/apk/latest']); - } else { - throw couldNotFindReleases; - } - } - - @override - AppNames getAppNames(String standardUrl) { - return AppNames('Mullvad-VPN', 'Mullvad-VPN'); - } -} - -class IzzyOnDroid implements AppSource { - @override - late String host = 'android.izzysoft.de'; - - @override - String standardizeURL(String url) { - RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); - RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); - if (match == null) { - throw notValidURL; - } - return url.substring(0, match.end); - } - - @override - Future getLatestAPKDetails(String standardUrl) async { - Response res = await get(Uri.parse(standardUrl)); - if (res.statusCode == 200) { - var parsedHtml = parse(res.body); - var multipleVersionApkUrls = parsedHtml - .querySelectorAll('a') - .where((element) => - element.attributes['href']?.toLowerCase().endsWith('.apk') ?? - false) - .map((e) => 'https://$host${e.attributes['href'] ?? ''}') - .toList(); - if (multipleVersionApkUrls.isEmpty) { - throw noAPKFound; - } - var version = parsedHtml - .querySelector('#keydata') - ?.querySelectorAll('b') - .where( - (element) => element.innerHtml.toLowerCase().contains('version')) - .toList()[0] - .parentNode - ?.parentNode - ?.children[1] - .innerHtml; - if (version == null) { - throw couldNotFindLatestVersion; - } - return APKDetails(version, [multipleVersionApkUrls[0]]); - } else { - throw couldNotFindReleases; - } - } - - @override - AppNames getAppNames(String standardUrl) { - return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); - } +abstract class MassAppSource { + late String name; + late List requiredArgs; + Future> getUrls(List args); } class SourceProvider { + // Add more source classes here so they are available via the service List sources = [ GitHub(), GitLab(), @@ -404,9 +117,9 @@ class SourceProvider { Signal() ]; + // Add more mass source classes here so they are available via the service List massSources = [GitHubStars()]; - // Add more source classes here so they are available via the service AppSource getSource(String url) { AppSource? source; for (var s in sources) { @@ -461,37 +174,3 @@ class SourceProvider { List getSourceHosts() => sources.map((e) => e.host).toList(); } - -abstract class MassAppSource { - late String name; - late List requiredArgs; - Future> getUrls(List args); -} - -class GitHubStars implements MassAppSource { - @override - late String name = 'GitHub Starred Repos'; - - @override - late List requiredArgs = ['Username']; - - @override - Future> getUrls(List args) async { - if (args.length != requiredArgs.length) { - throw 'Wrong number of arguments provided'; - } - Response res = - await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); - if (res.statusCode == 200) { - return (jsonDecode(res.body) as List) - .map((e) => e['html_url'] as String) - .toList(); - } else { - if (res.headers['x-ratelimit-remaining'] == '0') { - throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; - } - - throw 'Unable to find user\'s starred repos'; - } - } -}