diff --git a/README.md b/README.md index 293215e..b1bbfe3 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to Currently supported App sources: - [GitHub](https://github.com/) - [GitLab](https://gitlab.com/) +- [Codeberg](https://codeberg.org/) - [F-Droid](https://f-droid.org/) - [IzzyOnDroid](https://android.izzysoft.de/) - [Mullvad](https://mullvad.net/en/) diff --git a/lib/app_sources/codeberg.dart b/lib/app_sources/codeberg.dart new file mode 100644 index 0000000..e0335cd --- /dev/null +++ b/lib/app_sources/codeberg.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class Codeberg extends AppSource { + Codeberg() { + host = 'codeberg.org'; + + additionalSourceSpecificSettingFormItems = []; + + additionalSourceAppSpecificSettingFormItems = [ + [ + GeneratedFormSwitch('includePrereleases', + label: tr('includePrereleases'), defaultValue: false) + ], + [ + GeneratedFormSwitch('fallbackToOlderReleases', + label: tr('fallbackToOlderReleases'), defaultValue: true) + ], + [ + GeneratedFormTextField('filterReleaseTitlesByRegEx', + label: tr('filterReleaseTitlesByRegEx'), + required: false, + additionalValidators: [ + (value) { + if (value == null || value.isEmpty) { + return null; + } + try { + RegExp(value); + } catch (e) { + return tr('invalidRegEx'); + } + return null; + } + ]) + ] + ]; + + canSearch = true; + } + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + String? changeLogPageFromStandardUrl(String standardUrl) => + '$standardUrl/releases'; + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + bool includePrereleases = additionalSettings['includePrereleases']; + bool fallbackToOlderReleases = + additionalSettings['fallbackToOlderReleases']; + String? regexFilter = + (additionalSettings['filterReleaseTitlesByRegEx'] as String?) + ?.isNotEmpty == + true + ? additionalSettings['filterReleaseTitlesByRegEx'] + : null; + Response res = await get(Uri.parse( + 'https://$host/api/v1/repos${standardUrl.substring('https://$host'.length)}/releases')); + if (res.statusCode == 200) { + var releases = jsonDecode(res.body) as List; + + List getReleaseAPKUrls(dynamic release) => + (release['assets'] as List?) + ?.map((e) { + return e['name'] != null && e['browser_download_url'] != null + ? MapEntry(e['name'] as String, + e['browser_download_url'] as String) + : const MapEntry('', ''); + }) + .where((element) => element.key.toLowerCase().endsWith('.apk')) + .map((e) => e.value) + .toList() ?? + []; + + dynamic targetRelease; + + for (int i = 0; i < releases.length; i++) { + if (!fallbackToOlderReleases && i > 0) break; + if (!includePrereleases && releases[i]['prerelease'] == true) { + continue; + } + if (releases[i]['draft'] == true) { + // Draft releases not supported + } + var nameToFilter = releases[i]['name'] as String; + if (nameToFilter.trim().isEmpty) { + // Some leave titles empty so tag is used + nameToFilter = releases[i]['tag_name'] as String; + } + if (regexFilter != null && + !RegExp(regexFilter).hasMatch(nameToFilter.trim())) { + continue; + } + var apkUrls = getReleaseAPKUrls(releases[i]); + if (apkUrls.isEmpty && additionalSettings['trackOnly'] != true) { + continue; + } + targetRelease = releases[i]; + targetRelease['apkUrls'] = apkUrls; + break; + } + if (targetRelease == null) { + throw NoReleasesError(); + } + String? version = targetRelease['tag_name']; + if (version == null) { + throw NoVersionError(); + } + return APKDetails(version, targetRelease['apkUrls'] as List, + getAppNames(standardUrl)); + } else { + throw getObtainiumHttpError(res); + } + } + + 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]); + } + + @override + Future> search(String query) async { + Response res = await get(Uri.parse( + 'https://$host/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100')); + if (res.statusCode == 200) { + Map urlsWithDescriptions = {}; + for (var e in (jsonDecode(res.body)['data'] as List)) { + urlsWithDescriptions.addAll({ + e['html_url'] as String: e['description'] != null + ? e['description'] as String + : tr('noDescription') + }); + } + return urlsWithDescriptions; + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index c4bbbd6..ff3f75e 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -24,6 +24,7 @@ class AddAppPage extends StatefulWidget { class _AddAppPageState extends State { bool gettingAppInfo = false; + bool searching = false; String userInput = ''; String searchQuery = ''; @@ -37,6 +38,8 @@ class _AddAppPageState extends State { SourceProvider sourceProvider = SourceProvider(); AppsProvider appsProvider = context.read(); + bool doingSomething = gettingAppInfo || searching; + changeUserInput(String input, bool valid, bool isBuilding) { userInput = input; if (!isBuilding) { @@ -198,7 +201,7 @@ class _AddAppPageState extends State { gettingAppInfo ? const CircularProgressIndicator() : ElevatedButton( - onPressed: gettingAppInfo || + onPressed: doingSomething || pickedSource == null || (pickedSource! .combinedAppSpecificSettingFormItems @@ -249,9 +252,12 @@ class _AddAppPageState extends State { width: 16, ), ElevatedButton( - onPressed: searchQuery.isEmpty || gettingAppInfo + onPressed: searchQuery.isEmpty || doingSomething ? null : () { + setState(() { + searching = true; + }); Future.wait(sourceProvider.sources .where((e) => e.canSearch) .map((e) => @@ -293,6 +299,10 @@ class _AddAppPageState extends State { } }).catchError((e) { showError(e, context); + }).whenComplete(() { + setState(() { + searching = false; + }); }); }, child: Text(tr('search'))) diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 3929496..4d0eebd 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -7,6 +7,7 @@ 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'; @@ -19,7 +20,6 @@ import 'package:obtainium/app_sources/steammobile.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart'; -import 'package:obtainium/providers/settings_provider.dart'; class AppNames { late String author; @@ -269,6 +269,7 @@ class SourceProvider { List sources = [ GitHub(), GitLab(), + Codeberg(), FDroid(), IzzyOnDroid(), Mullvad(),