diff --git a/README.md b/README.md index 915d851..cf57007 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Currently supported App sources: - [Signal](https://signal.org/) - [SourceForge](https://sourceforge.net/) - [SourceHut](https://git.sr.ht/) +- [Aptoide](https://aptoide.com/) - [APKMirror](https://apkmirror.com/) (Track-Only) - [APKPure](https://apkpure.com/) - [Huawei AppGallery](https://appgallery.huawei.com/) diff --git a/lib/app_sources/aptoide.dart b/lib/app_sources/aptoide.dart new file mode 100644 index 0000000..0b7f3f4 --- /dev/null +++ b/lib/app_sources/aptoide.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +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 Aptoide extends AppSource { + Aptoide() { + host = 'aptoide.com'; + name = tr('Aptoide'); + allowSubDomains = true; + } + + @override + String sourceSpecificStandardizeURL(String url) { + RegExp standardUrlRegEx = RegExp('^https?://([^\\.]+\\.){2,}$host'); + RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); + if (match == null) { + throw InvalidURLError(name); + } + return url.substring(0, match.end); + } + + @override + Future tryInferringAppId(String standardUrl, + {Map additionalSettings = const {}}) async { + return (await getLatestAPKDetails(standardUrl, additionalSettings)).version; + } + + @override + Future getLatestAPKDetails( + String standardUrl, + Map additionalSettings, + ) async { + var res = await sourceRequest(standardUrl); + if (res.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var idMatch = RegExp('"app":{"id":[0-9]+').firstMatch(res.body); + String? id; + if (idMatch != null) { + id = res.body.substring(idMatch.start + 12, idMatch.end); + } else { + throw NoReleasesError(); + } + var res2 = + await sourceRequest('https://ws2.aptoide.com/api/7/getApp/app_id/$id'); + if (res2.statusCode != 200) { + throw getObtainiumHttpError(res); + } + var appDetails = jsonDecode(res2.body)?['nodes']?['meta']?['data']; + String appName = appDetails?['name'] ?? tr('app'); + String author = appDetails?['developer']?['name'] ?? name; + String? dateStr = appDetails?['updated']; + String? version = appDetails?['file']?['vername']; + String? apkUrl = appDetails?['file']?['path']; + if (version == null) { + throw NoVersionError(); + } + if (apkUrl == null) { + throw NoAPKError(); + } + DateTime? relDate; + if (dateStr != null) { + relDate = DateTime.parse(dateStr); + } + + return APKDetails( + version, getApkUrlsFromUrls([apkUrl]), AppNames(author, appName), + releaseDate: relDate); + } + + @override + Future>> search(String query, + {Map querySettings = const {}}) async { + Response res = await sourceRequest( + 'https://search.$host/?q=${Uri.encodeQueryComponent(query)}'); + if (res.statusCode == 200) { + Map> urlsWithDescriptions = {}; + parse(res.body).querySelectorAll('.package-header').forEach((e) { + String? url = e.attributes['href']; + if (url != null) { + try { + standardizeUrl(url); + } catch (e) { + url = null; + } + } + if (url != null) { + urlsWithDescriptions[url] = [ + e.querySelector('.package-name')?.text.trim() ?? '', + e.querySelector('.package-summary')?.text.trim() ?? + tr('noDescription') + ]; + } + }); + return urlsWithDescriptions; + } else { + throw getObtainiumHttpError(res); + } + } +} diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 5494362..c9433b7 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -9,6 +9,7 @@ import 'package:html/dom.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/apkmirror.dart'; import 'package:obtainium/app_sources/apkpure.dart'; +import 'package:obtainium/app_sources/aptoide.dart'; import 'package:obtainium/app_sources/codeberg.dart'; import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/fdroidrepo.dart'; @@ -325,6 +326,7 @@ abstract class AppSource { bool enforceTrackOnly = false; bool changeLogIfAnyIsMarkDown = true; bool appIdInferIsOptional = false; + bool allowSubDomains = false; AppSource() { name = runtimeType.toString(); @@ -522,13 +524,14 @@ class SourceProvider { Jenkins(), SourceForge(), SourceHut(), + Aptoide(), APKMirror(), APKPure(), HuaweiAppGallery(), // APKCombo(), // Can't get past their scraping blocking yet (get 403 Forbidden) Mullvad(), Signal(), - VLC(), // As of 2023-08-26 this site randomly messes up the 'latest' version (one minute it's 3.5.4, next minute back to 3.5.3) + VLC(), // WhatsApp(), // As of 2023-03-20 this is unusable as the version on the webpage is months out of date TelegramApp(), SteamMobile(), @@ -554,7 +557,9 @@ class SourceProvider { } AppSource? source; for (var s in sources.where((element) => element.host != null)) { - if (RegExp('://${s.host}(/|\\z)?').hasMatch(url)) { + if (RegExp( + '://${s.allowSubDomains ? '([^\\.]+\\.)*' : ''}${s.host}(/|\\z)?') + .hasMatch(url)) { source = s; break; }