diff --git a/README.md b/README.md index 293215e..4337bde 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/) @@ -18,6 +19,8 @@ Currently supported App sources: - Third Party F-Droid Repos - Any URLs ending with `/fdroid/`, where `` can be anything - most often `repo` - [Steam](https://store.steampowered.com/mobile) +- "HTML" (Fallback) + - Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked) ## Limitations - App installs happen asynchronously and the success/failure of an install cannot be determined directly. This results in install statuses and versions sometimes being out of sync with the OS until the next launch or until the problem is manually corrected. 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/app_sources/html.dart b/lib/app_sources/html.dart new file mode 100644 index 0000000..984c20e --- /dev/null +++ b/lib/app_sources/html.dart @@ -0,0 +1,47 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/custom_errors.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class HTML extends AppSource { + @override + String standardizeURL(String url) { + return url; + } + + @override + String? changeLogPageFromStandardUrl(String standardUrl) => null; + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + var uri = Uri.parse(standardUrl); + Response res = await get(uri); + if (res.statusCode == 200) { + List links = parse(res.body) + .querySelectorAll('a') + .map((element) => element.attributes['href'] ?? '') + .where((element) => element.toLowerCase().endsWith('.apk')) + .toList(); + links.sort((a, b) => a.split('/').last.compareTo(b.split('/').last)); + if (links.isEmpty) { + throw NoReleasesError(); + } + var rel = links.last; + var apkName = rel.split('/').last; + var version = apkName.substring(0, apkName.length - 4); + List apkUrls = [rel] + .map((e) => e.toLowerCase().startsWith('http://') || + e.toLowerCase().startsWith('https://') + ? e + : '${uri.origin}/$e') + .toList(); + return APKDetails(version, apkUrls, AppNames(uri.host, tr('app'))); + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 04b4add..7594eea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart'; // ignore: implementation_imports import 'package:easy_localization/src/localization.dart'; -const String currentVersion = '0.9.15'; +const String currentVersion = '0.10.00'; const String currentReleaseTag = 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 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..d03f36b 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -7,11 +7,13 @@ 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'; import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/app_sources/izzyondroid.dart'; +import 'package:obtainium/app_sources/html.dart'; import 'package:obtainium/app_sources/mullvad.dart'; import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/sourceforge.dart'; @@ -19,7 +21,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; @@ -154,6 +155,10 @@ class App { // Ensure the input is starts with HTTPS and has no WWW preStandardizeUrl(String url) { + var firstDotIndex = url.indexOf('.'); + if (!(firstDotIndex >= 0 && firstDotIndex != url.length - 1)) { + throw UnsupportedURLError(); + } if (url.toLowerCase().indexOf('http://') != 0 && url.toLowerCase().indexOf('https://') != 0) { url = 'https://$url'; @@ -269,6 +274,7 @@ class SourceProvider { List sources = [ GitHub(), GitLab(), + Codeberg(), FDroid(), IzzyOnDroid(), Mullvad(), @@ -276,7 +282,8 @@ class SourceProvider { SourceForge(), APKMirror(), FDroidRepo(), - SteamMobile() + SteamMobile(), + HTML() // This should ALWAYS be the last option as they are tried in order ]; // Add more mass url source classes here so they are available via the service diff --git a/pubspec.lock b/pubspec.lock index 3289058..9c13b99 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -182,7 +182,7 @@ packages: name: file_picker url: "https://pub.dartlang.org" source: hosted - version: "5.2.4" + version: "5.2.5" flutter: dependency: "direct main" description: flutter @@ -356,7 +356,7 @@ packages: name: mime url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "1.0.4" nested: dependency: transitive description: @@ -531,7 +531,7 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.15" + version: "2.0.16" shared_preferences_android: dependency: transitive description: @@ -539,13 +539,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.14" - shared_preferences_ios: + shared_preferences_foundation: dependency: transitive description: - name: shared_preferences_ios + name: shared_preferences_foundation url: "https://pub.dartlang.org" source: hosted - version: "2.1.1" + version: "2.1.0" shared_preferences_linux: dependency: transitive description: @@ -553,13 +553,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" - shared_preferences_macos: - dependency: transitive - description: - name: shared_preferences_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.5" shared_preferences_platform_interface: dependency: transitive description: @@ -599,14 +592,14 @@ packages: name: sqflite url: "https://pub.dartlang.org" source: hosted - version: "2.2.2" + version: "2.2.3" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "2.4.0+2" + version: "2.4.1" stack_trace: dependency: transitive description: @@ -634,7 +627,7 @@ packages: name: synchronized url: "https://pub.dartlang.org" source: hosted - version: "3.0.0+3" + version: "3.0.1" term_glyph: dependency: transitive description: @@ -753,14 +746,14 @@ packages: name: webview_flutter_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.1" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview url: "https://pub.dartlang.org" source: hosted - version: "3.0.1" + version: "3.0.2" win32: dependency: transitive description: @@ -774,7 +767,7 @@ packages: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.2.0+2" + version: "0.2.0+3" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4fd1542..7b3dd87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.9.15+105 # When changing this, update the tag in main() accordingly +version: 0.10.00+106 # When changing this, update the tag in main() accordingly environment: sdk: '>=2.18.2 <3.0.0'