Initial third party F-Droid repo support

Plus various bugfixes
And version increment
This commit is contained in:
Imran Remtulla
2022-12-15 21:22:03 -05:00
parent 6d0cac5894
commit f6ca5d42e8
19 changed files with 197 additions and 101 deletions

View File

@@ -14,7 +14,7 @@ class APKMirror extends AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -43,13 +43,12 @@ class APKMirror extends AppSource {
if (version == null || version.isEmpty) {
throw NoVersionError();
}
return APKDetails(version, []);
return APKDetails(version, [], getAppNames(standardUrl));
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
@@ -7,6 +8,7 @@ import 'package:obtainium/providers/source_provider.dart';
class FDroid extends AppSource {
FDroid() {
host = 'f-droid.org';
name = tr('fdroid');
}
@override
@@ -20,7 +22,7 @@ class FDroid extends AppSource {
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
match = standardUrlRegExA.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -29,12 +31,13 @@ class FDroid extends AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
String? tryInferringAppId(String standardUrl) {
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return Uri.parse(standardUrl).pathSegments.last;
}
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix) {
Response res, String apkUrlPrefix, String standardUrl) {
if (res.statusCode == 200) {
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
if (releases.isEmpty) {
@@ -48,7 +51,8 @@ class FDroid extends AppSource {
.where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList();
return APKDetails(latestVersion, apkUrls);
return APKDetails(latestVersion, apkUrls,
AppNames(name, Uri.parse(standardUrl).pathSegments.last));
} else {
throw NoReleasesError();
}
@@ -61,11 +65,7 @@ class FDroid extends AppSource {
String? appId = tryInferringAppId(standardUrl);
return getAPKUrlsFromFDroidPackagesAPIResponse(
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
'https://f-droid.org/repo/$appId');
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
'https://f-droid.org/repo/$appId',
standardUrl);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.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 FDroidRepo extends AppSource {
FDroidRepo() {
name = tr('fdroidThirdPartyRepo');
additionalSourceAppSpecificFormItems = [
[
GeneratedFormItem(
label: tr('appId'),
hint: tr('reposHaveMultipleApps'),
required: true,
key: 'appId')
]
];
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegExp =
RegExp('^https?://.+/fdroid/(repo(/|\\?)|repo\$)');
RegExpMatch? match = standardUrlRegExp.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return findGeneratedFormValueByKey(
additionalSourceAppSpecificFormItems
.reduce((value, element) => [...value, ...element]),
additionalData,
'appId');
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData,
{bool trackOnly = false}) async {
String? appId =
tryInferringAppId(standardUrl, additionalData: additionalData);
if (appId == null) {
throw NoReleasesError();
}
var res = await get(Uri.parse('$standardUrl/index.xml'));
if (res.statusCode == 200) {
var body = parse(res.body);
var foundApps = body
.querySelectorAll('application')
.where((element) => element.attributes['id'] == appId)
.toList();
if (foundApps.isEmpty) {
throw NoReleasesError();
}
var authorName = body.querySelector('repo')?.attributes['name'] ?? name;
var appName = foundApps[0].querySelector('name')?.innerHtml ?? appId;
var releases = foundApps[0].querySelectorAll('package');
String? latestVersion = releases[0].querySelector('version')?.innerHtml;
if (latestVersion == null) {
throw NoVersionError();
}
List<String> apkUrls = releases
.where((element) =>
element.querySelector('version')?.innerHtml == latestVersion &&
element.querySelector('apkname') != null)
.map((e) => '$standardUrl/${e.querySelector('apkname')!.innerHtml}')
.toList();
return APKDetails(latestVersion, apkUrls, AppNames(authorName, appName));
} else {
throw NoReleasesError();
}
}
}

View File

@@ -90,7 +90,7 @@ class GitHub extends AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -162,14 +162,14 @@ class GitHub extends AppSource {
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
return APKDetails(version, targetRelease['apkUrls'] as List<String>,
getAppNames(standardUrl));
} else {
rateLimitErrorCheck(res);
throw getObtainiumHttpError(res);
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');

View File

@@ -14,7 +14,7 @@ class GitLab extends AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -56,15 +56,9 @@ class GitLab extends AppSource {
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrls);
return APKDetails(version, apkUrls, GitHub().getAppNames(standardUrl));
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
}

View File

@@ -13,7 +13,7 @@ class IzzyOnDroid extends AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -22,7 +22,8 @@ class IzzyOnDroid extends AppSource {
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
String? tryInferringAppId(String standardUrl) {
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return FDroid().tryInferringAppId(standardUrl);
}
@@ -34,11 +35,7 @@ class IzzyOnDroid extends AppSource {
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
await get(
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
'https://android.izzysoft.de/frepo/$appId');
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
'https://android.izzysoft.de/frepo/$appId',
standardUrl);
}
}

View File

@@ -13,7 +13,7 @@ class Mullvad extends AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -38,14 +38,11 @@ class Mullvad extends AppSource {
throw NoVersionError();
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
version,
['https://mullvad.net/download/app/apk/latest'],
AppNames(name, 'Mullvad-VPN'));
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
}

View File

@@ -30,12 +30,9 @@ class Signal extends AppSource {
if (version == null) {
throw NoVersionError();
}
return APKDetails(version, apkUrls);
return APKDetails(version, apkUrls, AppNames(name, 'Signal'));
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
}

View File

@@ -13,7 +13,7 @@ class SourceForge extends AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(runtimeType.toString());
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@@ -50,15 +50,13 @@ class SourceForge extends AppSource {
apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version)
.toList();
return APKDetails(version, apkUrlList);
return APKDetails(
version,
apkUrlList,
AppNames(
name, standardUrl.substring(standardUrl.lastIndexOf('/') + 1)));
} else {
throw NoReleasesError();
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
}
}

View File

@@ -28,7 +28,11 @@ class GeneratedFormItem {
this.belowWidgets = const [],
this.hint,
this.opts,
this.key = 'default'});
this.key = 'default'}) {
if (type != FormItemType.string) {
required = false;
}
}
}
class GeneratedForm extends StatefulWidget {

View File

@@ -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.8.12';
const String currentVersion = '0.8.13';
const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@@ -87,7 +87,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
if (e is RateLimitError || e is SocketException) {
var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15;
logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes,
args: [e.runtimeType.toString(), remainingMinutes.toString()]));
args: [(e as AppSource).name, remainingMinutes.toString()]));
AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes),
Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: {
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch

View File

@@ -41,12 +41,12 @@ class _AddAppPageState extends State<AddAppPage> {
userInput = input;
fn() {
var source = valid ? sourceProvider.getSource(userInput) : null;
if (pickedSource != source) {
if (pickedSource.runtimeType != source.runtimeType) {
pickedSource = source;
sourceSpecificAdditionalData =
source != null ? source.additionalSourceAppSpecificDefaults : [];
sourceSpecificDataIsValid = source != null
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
? !sourceProvider.ifSourceAppsRequireAdditionalData(source)
: true;
}
}
@@ -308,10 +308,8 @@ class _AddAppPageState extends State<AddAppPage> {
height: 64,
),
Text(
tr('additionalOptsFor', args: [
pickedSource?.runtimeType.toString() ??
tr('source')
]),
tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]),
style: TextStyle(
color:
Theme.of(context).colorScheme.primary)),
@@ -383,16 +381,20 @@ class _AddAppPageState extends State<AddAppPage> {
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://${e.host}',
mode:
LaunchMode.externalApplication);
},
onTap: e.host != null
? () {
launchUrlString(
'https://${e.host}',
mode: LaunchMode
.externalApplication);
}
: null,
child: Text(
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: const TextStyle(
decoration:
TextDecoration.underline,
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: TextStyle(
decoration: e.host != null
? TextDecoration.underline
: TextDecoration.none,
fontStyle: FontStyle.italic),
)))
.toList()

View File

@@ -232,9 +232,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
return GeneratedFormModal(
title: tr('searchX',
args: [
source
.runtimeType
.toString()
source.name
]),
items: [
[
@@ -319,9 +317,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
});
},
child: Text(tr('searchX', args: [
source.runtimeType.toString()
])))
child: Text(
tr('searchX', args: [source.name])))
]))
.toList(),
...sourceProvider.massUrlSources

View File

@@ -8,6 +8,7 @@ import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/apkmirror.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';
@@ -28,8 +29,9 @@ class AppNames {
class APKDetails {
late String version;
late List<String> apkUrls;
late AppNames names;
APKDetails(this.version, this.apkUrls);
APKDetails(this.version, this.apkUrls, this.names);
}
class App {
@@ -106,11 +108,11 @@ class App {
// Ensure the input is starts with HTTPS and has no WWW
preStandardizeUrl(String url) {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = url.toLowerCase();
if (url.indexOf('http://') != 0 && url.indexOf('https://') != 0) {
url = 'https://$url';
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
if (url.indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
url = url
@@ -135,8 +137,14 @@ List<String> getLinksFromParsedHTML(
.toList();
class AppSource {
late String host;
String? host;
late String name;
bool enforceTrackOnly = false;
AppSource() {
name = runtimeType.toString();
}
String standardizeURL(String url) {
throw NotImplementedError();
}
@@ -147,10 +155,6 @@ class AppSource {
throw NotImplementedError();
}
AppNames getAppNames(String standardUrl) {
throw NotImplementedError();
}
// Different Sources may need different kinds of additional data for Apps
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
List<String> additionalSourceAppSpecificDefaults = [];
@@ -168,7 +172,7 @@ class AppSource {
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError();
return null;
}
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
@@ -180,7 +184,8 @@ class AppSource {
throw NotImplementedError();
}
String? tryInferringAppId(String standardUrl) {
String? tryInferringAppId(String standardUrl,
{List<String> additionalData = const []}) {
return null;
}
}
@@ -206,7 +211,8 @@ class SourceProvider {
Mullvad(),
Signal(),
SourceForge(),
APKMirror()
APKMirror(),
FDroidRepo()
];
// Add more mass url source classes here so they are available via the service
@@ -215,12 +221,23 @@ class SourceProvider {
AppSource getSource(String url) {
url = preStandardizeUrl(url);
AppSource? source;
for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) {
for (var s in sources.where((element) => element.host != null)) {
if (url.contains('://${s.host}')) {
source = s;
break;
}
}
if (source == null) {
for (var s in sources.where((element) => element.host == null)) {
try {
s.standardizeURL(url);
source = s;
break;
} catch (e) {
//
}
}
}
if (source == null) {
throw UnsupportedURLError();
}
@@ -252,7 +269,7 @@ class SourceProvider {
return false;
}
}
return sources.map((e) => e.host).contains(parts.last);
return true;
}
Future<App> getApp(AppSource source, String url, List<String> additionalData,
@@ -262,7 +279,6 @@ class SourceProvider {
bool trackOnly = false,
String? installedVersion}) async {
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
if (apk.apkUrls.isEmpty && !trackOnly) {
@@ -271,13 +287,14 @@ class SourceProvider {
String apkVersion = apk.version.replaceAll('/', '-');
return App(
id ??
source.tryInferringAppId(standardUrl) ??
generateTempID(names, source),
source.tryInferringAppId(standardUrl,
additionalData: additionalData) ??
generateTempID(apk.names, source),
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
apk.names.author[0].toUpperCase() + apk.names.author.substring(1),
name.trim().isNotEmpty
? name
: names.name[0].toUpperCase() + names.name.substring(1),
: apk.names.name[0].toUpperCase() + apk.names.name.substring(1),
installedVersion,
apkVersion,
apk.apkUrls,