diff --git a/README.md b/README.md index 18692e7..11c9ca5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Currently supported App sources: - [IzzyOnDroid](https://android.izzysoft.de/) - [Mullvad](https://mullvad.net/en/) - [Signal](https://signal.org/) +- [APKMirror](https://apkmirror.com/) ## Limitations - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. diff --git a/lib/app_sources/apkmirror.dart b/lib/app_sources/apkmirror.dart new file mode 100644 index 0000000..c11eb95 --- /dev/null +++ b/lib/app_sources/apkmirror.dart @@ -0,0 +1,112 @@ +import 'package:html/parser.dart'; +import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/providers/source_provider.dart'; + +class APKMirror implements AppSource { + @override + late String host = 'apkmirror.com'; + + @override + String standardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw notValidURL(runtimeType.toString()); + } + return url.substring(0, match.end); + } + + @override + String? changeLogPageFromStandardUrl(String standardUrl) => + '$standardUrl#whatsnew'; + + @override + Future apkUrlPrefetchModifier(String apkUrl) async { + var originalUri = Uri.parse(apkUrl); + var res = await get(originalUri); + if (res.statusCode != 200) { + throw false; + } + var href = + parse(res.body).querySelector('.downloadButton')?.attributes['href']; + if (href == null) { + throw false; + } + var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: { + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' + }); + if (res2.statusCode != 200) { + throw false; + } + var links = parse(res2.body) + .querySelectorAll('a') + .where((element) => element.innerHtml == 'here') + .map((e) => e.attributes['href']) + .where((element) => element != null) + .toList(); + if (links.isEmpty) { + throw false; + } + return '${originalUri.origin}${links[0]}'; + } + + @override + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { + Response res = await get(Uri.parse('$standardUrl/feed')); + if (res.statusCode != 200) { + throw couldNotFindReleases; + } + var nextUrl = parse(res.body) + .querySelector('item') + ?.querySelector('link') + ?.nextElementSibling + ?.innerHtml; + if (nextUrl == null) { + throw couldNotFindReleases; + } + Response res2 = await get(Uri.parse(nextUrl), headers: { + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0' + }); + if (res2.statusCode != 200) { + throw couldNotFindReleases; + } + var html2 = parse(res2.body); + var origin = Uri.parse(standardUrl).origin; + List apkUrls = html2 + .querySelectorAll('.apkm-badge') + .map((e) => e.innerHtml != 'APK' + ? '' + : e.previousElementSibling?.attributes['href'] ?? '') + .where((element) => element.isNotEmpty) + .map((e) => '$origin$e') + .toList(); + if (apkUrls.isEmpty) { + throw noAPKFound; + } + var version = html2.querySelector('span.active.accent_color')?.innerHtml; + if (version == null) { + throw couldNotFindLatestVersion; + } + return APKDetails(version, apkUrls); + } + + @override + AppNames getAppNames(String standardUrl) { + String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); + List names = temp.substring(temp.indexOf('/') + 1).split('/'); + return AppNames(names[1], names[2]); + } + + @override + List> additionalDataFormItems = []; + + @override + List additionalDataDefaults = []; + + @override + List moreSourceSettingsFormItems = []; +} diff --git a/lib/app_sources/fdroid.dart b/lib/app_sources/fdroid.dart index cf1bf75..d578e16 100644 --- a/lib/app_sources/fdroid.dart +++ b/lib/app_sources/fdroid.dart @@ -26,6 +26,9 @@ class FDroid implements AppSource { @override String? changeLogPageFromStandardUrl(String standardUrl) => null; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index e4675b2..dfb125d 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -33,6 +33,9 @@ class GitHub implements AppSource { String? changeLogPageFromStandardUrl(String standardUrl) => '$standardUrl/releases'; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/app_sources/gitlab.dart b/lib/app_sources/gitlab.dart index 9b7ea44..058da5d 100644 --- a/lib/app_sources/gitlab.dart +++ b/lib/app_sources/gitlab.dart @@ -22,6 +22,9 @@ class GitLab implements AppSource { String? changeLogPageFromStandardUrl(String standardUrl) => '$standardUrl/-/releases'; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/app_sources/izzyondroid.dart b/lib/app_sources/izzyondroid.dart index 7173536..2c75f5c 100644 --- a/lib/app_sources/izzyondroid.dart +++ b/lib/app_sources/izzyondroid.dart @@ -20,6 +20,9 @@ class IzzyOnDroid implements AppSource { @override String? changeLogPageFromStandardUrl(String standardUrl) => null; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/app_sources/mullvad.dart b/lib/app_sources/mullvad.dart index a2d26d1..71b6144 100644 --- a/lib/app_sources/mullvad.dart +++ b/lib/app_sources/mullvad.dart @@ -21,6 +21,9 @@ class Mullvad implements AppSource { String? changeLogPageFromStandardUrl(String standardUrl) => 'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md'; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/app_sources/signal.dart b/lib/app_sources/signal.dart index 76c1caa..23d8fdd 100644 --- a/lib/app_sources/signal.dart +++ b/lib/app_sources/signal.dart @@ -15,6 +15,9 @@ class Signal implements AppSource { @override String? changeLogPageFromStandardUrl(String standardUrl) => null; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/app_sources/sourceforge.dart b/lib/app_sources/sourceforge.dart index 7059b8d..17f02f0 100644 --- a/lib/app_sources/sourceforge.dart +++ b/lib/app_sources/sourceforge.dart @@ -20,6 +20,9 @@ class SourceForge implements AppSource { @override String? changeLogPageFromStandardUrl(String standardUrl) => null; + @override + Future apkUrlPrefetchModifier(String apkUrl) async => apkUrl; + @override Future getLatestAPKDetails( String standardUrl, List additionalData) async { diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index dd0b431..01e6f04 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -64,6 +64,9 @@ class AppsProvider with ChangeNotifier { } Future downloadApp(String apkUrl, String appId) async { + apkUrl = await SourceProvider() + .getSource(apps[appId]!.app.url) + .apkUrlPrefetchModifier(apkUrl); StreamedResponse response = await Client().send(Request('GET', Uri.parse(apkUrl))); File downloadFile = @@ -420,7 +423,10 @@ class _APKPickerState extends State { Text('${widget.app.name} has more than one package:'), const SizedBox(height: 16), ...widget.app.apkUrls.map((u) => RadioListTile( - title: Text(Uri.parse(u).pathSegments.last), + title: Text(Uri.parse(u) + .pathSegments + .where((element) => element.isNotEmpty) + .last), value: u, groupValue: apkUrl, onChanged: (String? val) { diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 37d03b5..9d0b730 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:html/dom.dart'; +import 'package:obtainium/app_sources/apkmirror.dart'; import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/gitlab.dart'; @@ -137,6 +138,7 @@ abstract class AppSource { late List additionalDataDefaults; late List moreSourceSettingsFormItems; String? changeLogPageFromStandardUrl(String standardUrl); + Future apkUrlPrefetchModifier(String apkUrl); } abstract class MassAppSource { @@ -154,7 +156,8 @@ class SourceProvider { IzzyOnDroid(), Mullvad(), Signal(), - SourceForge() + SourceForge(), + APKMirror() ]; // Add more mass source classes here so they are available via the service