Merge pull request #2350 from ImranR98/dev

- Minor wording changes (#2226, #2313, #2317, #2338)
- HTML bugfix: Incorrect URL resolution on redirected pages (#2315)
This commit is contained in:
Imran
2025-06-13 17:23:40 -04:00
committed by GitHub
46 changed files with 6932 additions and 5385 deletions

View File

@ -63,7 +63,7 @@ Or, contribute some configurations to the website by creating a PR at [this repo
Verification info: Verification info:
- Package ID: `dev.imranr.obtainium` - Package ID: `dev.imranr.obtainium`
- SHA-256 Hash of Signing Certificate: `B3:53:60:1F:6A:1D:5F:D6:60:3A:E2:F5:0B:E8:0C:F3:01:36:7B:86:B6:AB:8B:1F:66:24:3D:A9:6C:D5:73:62` - SHA-256 hash of signing certificate: `B3:53:60:1F:6A:1D:5F:D6:60:3A:E2:F5:0B:E8:0C:F3:01:36:7B:86:B6:AB:8B:1F:66:24:3D:A9:6C:D5:73:62`
- Note: The above signature is also valid for the F-Droid flavour of Obtainium, thanks to [reproducible builds](https://f-droid.org/docs/Reproducible_Builds/). - Note: The above signature is also valid for the F-Droid flavour of Obtainium, thanks to [reproducible builds](https://f-droid.org/docs/Reproducible_Builds/).
- [PGP Public Key](https://keyserver.ubuntu.com/pks/lookup?search=contact%40imranr.dev&fingerprint=on&op=index) (to verify APK hashes) - [PGP Public Key](https://keyserver.ubuntu.com/pks/lookup?search=contact%40imranr.dev&fingerprint=on&op=index) (to verify APK hashes)

View File

@ -12,8 +12,9 @@ class APKCombo extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
var match = standardUrlRegEx.firstMatch(url); var match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -22,25 +23,30 @@ class APKCombo extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
return { return {
"User-Agent": "curl/8.0.1", "User-Agent": "curl/8.0.1",
"Accept": "*/*", "Accept": "*/*",
"Connection": "keep-alive", "Connection": "keep-alive",
"Host": hosts[0] "Host": hosts[0],
}; };
} }
Future<List<MapEntry<String, String>>> getApkUrls( Future<List<MapEntry<String, String>>> getApkUrls(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest('$standardUrl/download/apk', {}); var res = await sourceRequest('$standardUrl/download/apk', {});
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
@ -65,7 +71,9 @@ class APKCombo extends AppSource {
String verCode = String verCode =
e.querySelector('.info .header .vercode')?.text.trim() ?? ''; e.querySelector('.info .header .vercode')?.text.trim() ?? '';
return MapEntry<String, String>( return MapEntry<String, String>(
arch != null ? '$arch-$verCode.apk' : '', url ?? ''); arch != null ? '$arch-$verCode.apk' : '',
url ?? '',
);
}).toList(); }).toList();
}) })
.reduce((value, element) => [...value, ...element]) .reduce((value, element) => [...value, ...element])
@ -74,8 +82,11 @@ class APKCombo extends AppSource {
} }
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl, String standardUrl, Future<String> apkUrlPrefetchModifier(
Map<String, dynamic> additionalSettings) async { String apkUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var freshURLs = await getApkUrls(standardUrl, additionalSettings); var freshURLs = await getApkUrls(standardUrl, additionalSettings);
var path2Match = Uri.parse(apkUrl).path; var path2Match = Uri.parse(apkUrl).path;
for (var url in freshURLs) { for (var url in freshURLs) {
@ -116,9 +127,10 @@ class APKCombo extends AppSource {
} }
} }
return APKDetails( return APKDetails(
version, version,
await getApkUrls(standardUrl, additionalSettings), await getApkUrls(standardUrl, additionalSettings),
AppNames(author, appName), AppNames(author, appName),
releaseDate: releaseDate); releaseDate: releaseDate,
);
} }
} }

View File

@ -17,37 +17,44 @@ class APKMirror extends AppSource {
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSwitch('fallbackToOlderReleases', GeneratedFormSwitch(
label: tr('fallbackToOlderReleases'), defaultValue: true) 'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
], ],
[ [
GeneratedFormTextField('filterReleaseTitlesByRegEx', GeneratedFormTextField(
label: tr('filterReleaseTitlesByRegEx'), 'filterReleaseTitlesByRegEx',
required: false, label: tr('filterReleaseTitlesByRegEx'),
additionalValidators: [ required: false,
(value) { additionalValidators: [
return regExValidator(value); (value) {
} return regExValidator(value);
]) },
] ],
),
],
]; ];
} }
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
return { return {
"User-Agent": "User-Agent":
"Obtainium/${(await getInstalledInfo(obtainiumId))?.versionName ?? '1.0.0'}" "Obtainium/${(await getInstalledInfo(obtainiumId))?.versionName ?? '1.0.0'}",
}; };
} }
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/apk/[^/]+/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/apk/[^/]+/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -68,12 +75,14 @@ class APKMirror extends AppSource {
additionalSettings['fallbackToOlderReleases'] == true; additionalSettings['fallbackToOlderReleases'] == true;
String? regexFilter = String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?) (additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty == ?.isNotEmpty ==
true true
? additionalSettings['filterReleaseTitlesByRegEx'] ? additionalSettings['filterReleaseTitlesByRegEx']
: null; : null;
Response res = Response res = await sourceRequest(
await sourceRequest('$standardUrl/feed/', additionalSettings); '$standardUrl/feed/',
additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var items = parse(res.body).querySelectorAll('item'); var items = parse(res.body).querySelectorAll('item');
dynamic targetRelease; dynamic targetRelease;
@ -95,11 +104,14 @@ class APKMirror extends AppSource {
.split(' ') .split(' ')
.sublist(0, 5) .sublist(0, 5)
.join(' '); .join(' ');
DateTime? releaseDate = DateTime? releaseDate = dateString != null
dateString != null ? HttpDate.parse('$dateString GMT') : null; ? HttpDate.parse('$dateString GMT')
: null;
String? version = titleString String? version = titleString
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0, ?.substring(
RegExp(' by ').allMatches(titleString).last.start) RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
RegExp(' by ').allMatches(titleString).last.start,
)
.trim(); .trim();
if (version == null || version.isEmpty) { if (version == null || version.isEmpty) {
version = titleString; version = titleString;
@ -107,8 +119,12 @@ class APKMirror extends AppSource {
if (version == null || version.isEmpty) { if (version == null || version.isEmpty) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, [], getAppNames(standardUrl), return APKDetails(
releaseDate: releaseDate); version,
[],
getAppNames(standardUrl),
releaseDate: releaseDate,
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -8,7 +8,7 @@ import 'package:obtainium/providers/source_provider.dart';
extension Unique<E, Id> on List<E> { extension Unique<E, Id> on List<E> {
List<E> unique([Id Function(E element)? id, bool inplace = true]) { List<E> unique([Id Function(E element)? id, bool inplace = true]) {
final ids = Set(); final ids = <dynamic>{};
var list = inplace ? this : List<E>.from(this); var list = inplace ? this : List<E>.from(this);
list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id)); list.retainWhere((x) => ids.add(id != null ? id(x) : x as Id));
return list; return list;
@ -23,33 +23,44 @@ class APKPure extends AppSource {
showReleaseDateAsVersionToggle = true; showReleaseDateAsVersionToggle = true;
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSwitch('fallbackToOlderReleases', GeneratedFormSwitch(
label: tr('fallbackToOlderReleases'), defaultValue: true) 'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
], ],
[ [
GeneratedFormSwitch('stayOneVersionBehind', GeneratedFormSwitch(
label: tr('stayOneVersionBehind'), defaultValue: false) 'stayOneVersionBehind',
label: tr('stayOneVersionBehind'),
defaultValue: false,
),
], ],
[ [
GeneratedFormSwitch('useFirstApkOfVersion', GeneratedFormSwitch(
label: tr('useFirstApkOfVersion'), defaultValue: true) 'useFirstApkOfVersion',
] label: tr('useFirstApkOfVersion'),
defaultValue: true,
),
],
]; ];
} }
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExB = RegExp( RegExp standardUrlRegExB = RegExp(
'^https?://m.${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+', '^https?://m.${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExB.firstMatch(url); RegExpMatch? match = standardUrlRegExB.firstMatch(url);
if (match != null) { if (match != null) {
var uri = Uri.parse(url); var uri = Uri.parse(url);
url = 'https://${uri.host.substring(2)}${uri.path}'; url = 'https://${uri.host.substring(2)}${uri.path}';
} }
RegExp standardUrlRegExA = RegExp( RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}(/+[^/]{2})?/+[^/]+/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url); match = standardUrlRegExA.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -58,15 +69,18 @@ class APKPure extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
getDetailsForVersion( Future<APKDetails> getDetailsForVersion(
List<Map<String, dynamic>> versionVariants, List<Map<String, dynamic>> versionVariants,
List<String> supportedArchs, List<String> supportedArchs,
Map<String, dynamic> additionalSettings) async { Map<String, dynamic> additionalSettings,
) async {
var apkUrls = versionVariants var apkUrls = versionVariants
.map((e) { .map((e) {
String appId = e['package_name']; String appId = e['package_name'];
@ -88,8 +102,9 @@ class APKPure extends AppSource {
String downloadUri = e['asset']['url']; String downloadUri = e['asset']['url'];
return MapEntry( return MapEntry(
'$appId-$versionCode-$architectureString.${type.toLowerCase()}', '$appId-$versionCode-$architectureString.${type.toLowerCase()}',
downloadUri); downloadUri,
);
}) })
.nonNulls .nonNulls
.toList() .toList()
@ -114,14 +129,20 @@ class APKPure extends AppSource {
apkUrls = [apkUrls.first]; apkUrls = [apkUrls.first];
} }
return APKDetails(version, apkUrls, AppNames(author, appName), return APKDetails(
releaseDate: releaseDate, changeLog: changeLog); version,
apkUrls,
AppNames(author, appName),
releaseDate: releaseDate,
changeLog: changeLog,
);
} }
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
if (forAPKDownload) { if (forAPKDownload) {
return null; return null;
} else { } else {
@ -145,19 +166,22 @@ class APKPure extends AppSource {
// request versions from API // request versions from API
var res = await sourceRequest( var res = await sourceRequest(
"https://tapi.pureapk.com/v3/get_app_his_version?package_name=$appId&hl=en", "https://tapi.pureapk.com/v3/get_app_his_version?package_name=$appId&hl=en",
additionalSettings); additionalSettings,
);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }
List<Map<String, dynamic>> apks = List<Map<String, dynamic>> apks = jsonDecode(
jsonDecode(res.body)['version_list'].cast<Map<String, dynamic>>(); res.body,
)['version_list'].cast<Map<String, dynamic>>();
// group by version // group by version
List<List<Map<String, dynamic>>> versions = apks List<List<Map<String, dynamic>>> versions = apks
.fold<Map<String, List<Map<String, dynamic>>>>({}, .fold<Map<String, List<Map<String, dynamic>>>>({}, (
(Map<String, List<Map<String, dynamic>>> val, Map<String, List<Map<String, dynamic>>> val,
Map<String, dynamic> element) { Map<String, dynamic> element,
) {
String v = element['version_name']; String v = element['version_name'];
if (!val.containsKey(v)) { if (!val.containsKey(v)) {
val[v] = []; val[v] = [];
@ -179,7 +203,10 @@ class APKPure extends AppSource {
throw NoReleasesError(); throw NoReleasesError();
} }
return await getDetailsForVersion( return await getDetailsForVersion(
v, supportedArchs, additionalSettings); v,
supportedArchs,
additionalSettings,
);
} catch (e) { } catch (e) {
if (additionalSettings['fallbackToOlderReleases'] != true || if (additionalSettings['fallbackToOlderReleases'] != true ||
i == versions.length - 1) { i == versions.length - 1) {

View File

@ -16,8 +16,9 @@ class Aptoide extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}', '^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -26,14 +27,20 @@ class Aptoide extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return (await getAppDetailsJSON( return (await getAppDetailsJSON(
standardUrl, additionalSettings))['package']; standardUrl,
additionalSettings,
))['package'];
} }
Future<Map<String, dynamic>> getAppDetailsJSON( Future<Map<String, dynamic>> getAppDetailsJSON(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(standardUrl, additionalSettings); var res = await sourceRequest(standardUrl, additionalSettings);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
@ -46,7 +53,9 @@ class Aptoide extends AppSource {
throw NoReleasesError(); throw NoReleasesError();
} }
var res2 = await sourceRequest( var res2 = await sourceRequest(
'https://ws2.aptoide.com/api/7/getApp/app_id/$id', additionalSettings); 'https://ws2.aptoide.com/api/7/getApp/app_id/$id',
additionalSettings,
);
if (res2.statusCode != 200) { if (res2.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }
@ -76,7 +85,10 @@ class Aptoide extends AppSource {
} }
return APKDetails( return APKDetails(
version, getApkUrlsFromUrls([apkUrl]), AppNames(author, appName), version,
releaseDate: relDate); getApkUrlsFromUrls([apkUrl]),
AppNames(author, appName),
releaseDate: relDate,
);
} }
} }

View File

@ -18,8 +18,9 @@ class Codeberg extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -36,8 +37,9 @@ class Codeberg extends AppSource {
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
return await gh.getLatestAPKDetailsCommon2(standardUrl, additionalSettings, return await gh.getLatestAPKDetailsCommon2(standardUrl, additionalSettings, (
(bool useTagUrl) async { bool useTagUrl,
) async {
return 'https://${hosts[0]}/api/v1/repos${standardUrl.substring('https://${hosts[0]}'.length)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100'; return 'https://${hosts[0]}/api/v1/repos${standardUrl.substring('https://${hosts[0]}'.length)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100';
}, null); }, null);
} }
@ -49,12 +51,15 @@ class Codeberg extends AppSource {
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
return gh.searchCommon( return gh.searchCommon(
query, query,
'https://${hosts[0]}/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100', 'https://${hosts[0]}/api/v1/repos/search?q=${Uri.encodeQueryComponent(query)}&limit=100',
'data', 'data',
querySettings: querySettings); querySettings: querySettings,
);
} }
} }

View File

@ -19,8 +19,9 @@ class CoolApk extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
r'^https?://(www\.)?coolapk\.com/apk/[^/]+', r'^https?://(www\.)?coolapk\.com/apk/[^/]+',
caseSensitive: false); caseSensitive: false,
);
var match = standardUrlRegEx.firstMatch(url); var match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -30,17 +31,19 @@ class CoolApk extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
String appId = Uri.parse(standardUrl).pathSegments.last; String appId = Uri.parse(standardUrl).pathSegments.last;
return appId; return appId;
} }
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
String appId = (await tryInferringAppId(standardUrl))!; String appId = (await tryInferringAppId(standardUrl))!;
String apiUrl = 'https://api2.coolapk.com'; String apiUrl = 'https://api2.coolapk.com';
@ -65,13 +68,19 @@ class CoolApk extends AppSource {
String changelog = detail['changelog']?.toString() ?? ''; String changelog = detail['changelog']?.toString() ?? '';
int? releaseDate = detail['lastupdate'] != null int? releaseDate = detail['lastupdate'] != null
? (detail['lastupdate'] is int ? (detail['lastupdate'] is int
? detail['lastupdate'] * 1000 ? detail['lastupdate'] * 1000
: int.parse(detail['lastupdate'].toString()) * 1000) : int.parse(detail['lastupdate'].toString()) * 1000)
: null; : null;
String aid = detail['id'].toString(); String aid = detail['id'].toString();
// get apk url // get apk url
String apkUrl = await _getLatestApkUrl(apiUrl, appId, aid, version, headers); String apkUrl = await _getLatestApkUrl(
apiUrl,
appId,
aid,
version,
headers,
);
if (apkUrl.isEmpty) { if (apkUrl.isEmpty) {
throw NoAPKError(); throw NoAPKError();
} }
@ -89,8 +98,13 @@ class CoolApk extends AppSource {
); );
} }
Future<String> _getLatestApkUrl(String apiUrl, String appId, String aid, Future<String> _getLatestApkUrl(
String version, Map<String, String>? headers) async { String apiUrl,
String appId,
String aid,
String version,
Map<String, String>? headers,
) async {
String url = '$apiUrl/v6/apk/download?pn=$appId&aid=$aid'; String url = '$apiUrl/v6/apk/download?pn=$appId&aid=$aid';
var res = await sourceRequest(url, {}, followRedirects: false); var res = await sourceRequest(url, {}, followRedirects: false);
if (res.statusCode >= 300 && res.statusCode < 400) { if (res.statusCode >= 300 && res.statusCode < 400) {
@ -102,13 +116,14 @@ class CoolApk extends AppSource {
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
var tokenPair = _getToken(); var tokenPair = _getToken();
// CoolAPK header // CoolAPK header
return { return {
'User-Agent': 'User-Agent':
'Dalvik/2.1.0 (Linux; U; Android 9; MI 8 SE MIUI/9.5.9) (#Build; Xiaomi; MI 8 SE; PKQ1.181121.001; 9) +CoolMarket/12.4.2-2208241-universal', 'Dalvik/2.1.0 (Linux; U; Android 9; MI 8 SE MIUI/9.5.9) (#Build; Xiaomi; MI 8 SE; PKQ1.181121.001; 9) +CoolMarket/12.4.2-2208241-universal',
'X-App-Id': 'com.coolapk.market', 'X-App-Id': 'com.coolapk.market',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'X-Sdk-Int': '30', 'X-Sdk-Int': '30',
@ -128,14 +143,15 @@ class CoolApk extends AppSource {
Map<String, String> _getToken() { Map<String, String> _getToken() {
final rand = Random(); final rand = Random();
String randHexString(int n) => String randHexString(int n) => List.generate(
List.generate(n, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')) n,
.join() (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0'),
.toUpperCase(); ).join().toUpperCase();
String randMacAddress() => String randMacAddress() => List.generate(
List.generate(6, (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0')) 6,
.join(':'); (_) => rand.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join(':');
// 加密算法来自 https://github.com/XiaoMengXinX/FuckCoolapkTokenV2、https://github.com/Coolapk-UWP/Coolapk-UWP // 加密算法来自 https://github.com/XiaoMengXinX/FuckCoolapkTokenV2、https://github.com/Coolapk-UWP/Coolapk-UWP
// device // device
@ -147,11 +163,13 @@ class CoolApk extends AppSource {
const buildNumber = 'SQ1D.220105.007'; const buildNumber = 'SQ1D.220105.007';
// generate deviceCode // generate deviceCode
String deviceCode = String deviceCode = base64.encode(
base64.encode('$aid; ; ; $mac; $manufactor; $brand; $model; $buildNumber'.codeUnits); '$aid; ; ; $mac; $manufactor; $brand; $model; $buildNumber'.codeUnits,
);
// generate timestamp // generate timestamp
String timeStamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); String timeStamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000)
.toString();
String base64TimeStamp = base64.encode(timeStamp.codeUnits); String base64TimeStamp = base64.encode(timeStamp.codeUnits);
String md5TimeStamp = md5.convert(timeStamp.codeUnits).toString(); String md5TimeStamp = md5.convert(timeStamp.codeUnits).toString();
String md5DeviceCode = md5.convert(deviceCode.codeUnits).toString(); String md5DeviceCode = md5.convert(deviceCode.codeUnits).toString();
@ -164,7 +182,8 @@ class CoolApk extends AppSource {
String md5Token = md5.convert(token.codeUnits).toString(); String md5Token = md5.convert(token.codeUnits).toString();
// generate salt and hash // generate salt and hash
String bcryptSalt = '\$2a\$10\$${base64TimeStamp.substring(0, 14)}/${md5Token.substring(0, 6)}u'; String bcryptSalt =
'\$2a\$10\$${base64TimeStamp.substring(0, 14)}/${md5Token.substring(0, 6)}u';
String bcryptResult = BCrypt.hashpw(md5Base64Token, bcryptSalt); String bcryptResult = BCrypt.hashpw(md5Base64Token, bcryptSalt);
String reBcryptResult = bcryptResult.replaceRange(0, 3, '\$2y'); String reBcryptResult = bcryptResult.replaceRange(0, 3, '\$2y');
String finalToken = 'v2${base64.encode(reBcryptResult.codeUnits)}'; String finalToken = 'v2${base64.encode(reBcryptResult.codeUnits)}';

View File

@ -11,20 +11,23 @@ class DirectAPKLink extends AppSource {
name = tr('directAPKLink'); name = tr('directAPKLink');
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
...html.additionalSourceAppSpecificSettingFormItems ...html.additionalSourceAppSpecificSettingFormItems
.where((element) => element .where(
.where((element) => element.key == 'requestHeader') (element) => element
.isNotEmpty) .where((element) => element.key == 'requestHeader')
.toList(), .isNotEmpty,
)
,
[ [
GeneratedFormDropdown( GeneratedFormDropdown(
'defaultPseudoVersioningMethod', 'defaultPseudoVersioningMethod',
[ [
MapEntry('partialAPKHash', tr('partialAPKHash')), MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('ETag', 'ETag') MapEntry('ETag', 'ETag'),
], ],
label: tr('defaultPseudoVersioningMethod'), label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash') defaultValue: 'partialAPKHash',
] ),
],
]; ];
excludeCommonSettingKeys = [ excludeCommonSettingKeys = [
'versionExtractionRegEx', 'versionExtractionRegEx',
@ -32,7 +35,7 @@ class DirectAPKLink extends AppSource {
'versionDetection', 'versionDetection',
'useVersionCodeAsOSVersion', 'useVersionCodeAsOSVersion',
'apkFilterRegEx', 'apkFilterRegEx',
'autoApkFilterByArch' 'autoApkFilterByArch',
]; ];
} }
@ -51,10 +54,13 @@ class DirectAPKLink extends AppSource {
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) { bool forAPKDownload = false,
return html.getRequestHeaders(additionalSettings, }) {
forAPKDownload: forAPKDownload); return html.getRequestHeaders(
additionalSettings,
forAPKDownload: forAPKDownload,
);
} }
@override @override
@ -62,8 +68,9 @@ class DirectAPKLink extends AppSource {
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
var additionalSettingsNew = var additionalSettingsNew = getDefaultValuesFromFormItems(
getDefaultValuesFromFormItems(html.combinedAppSpecificSettingFormItems); html.combinedAppSpecificSettingFormItems,
);
for (var s in additionalSettings.keys) { for (var s in additionalSettings.keys) {
if (additionalSettingsNew.containsKey(s)) { if (additionalSettingsNew.containsKey(s)) {
additionalSettingsNew[s] = additionalSettings[s]; additionalSettingsNew[s] = additionalSettings[s];

View File

@ -17,22 +17,28 @@ class FDroid extends AppSource {
canSearch = true; canSearch = true;
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormTextField('filterVersionsByRegEx', GeneratedFormTextField(
label: tr('filterVersionsByRegEx'), 'filterVersionsByRegEx',
required: false, label: tr('filterVersionsByRegEx'),
additionalValidators: [ required: false,
(value) { additionalValidators: [
return regExValidator(value); (value) {
} return regExValidator(value);
]) },
],
),
], ],
[ [
GeneratedFormSwitch('trySelectingSuggestedVersionCode', GeneratedFormSwitch(
label: tr('trySelectingSuggestedVersionCode')) 'trySelectingSuggestedVersionCode',
label: tr('trySelectingSuggestedVersionCode'),
),
], ],
[ [
GeneratedFormSwitch('autoSelectHighestVersionCode', GeneratedFormSwitch(
label: tr('autoSelectHighestVersionCode')) 'autoSelectHighestVersionCode',
label: tr('autoSelectHighestVersionCode'),
),
], ],
]; ];
} }
@ -40,16 +46,18 @@ class FDroid extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExB = RegExp( RegExp standardUrlRegExB = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+packages/+[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/+[^/]+/+packages/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExB.firstMatch(url); RegExpMatch? match = standardUrlRegExB.firstMatch(url);
if (match != null) { if (match != null) {
url = url =
'https://${Uri.parse(match.group(0)!).host}/packages/${Uri.parse(url).pathSegments.where((s) => s.trim().isNotEmpty).last}'; 'https://${Uri.parse(match.group(0)!).host}/packages/${Uri.parse(url).pathSegments.where((s) => s.trim().isNotEmpty).last}';
} }
RegExp standardUrlRegExA = RegExp( RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/+packages/+[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/+packages/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url); match = standardUrlRegExA.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -58,8 +66,10 @@ class FDroid extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
@ -71,22 +81,28 @@ class FDroid extends AppSource {
String? appId = await tryInferringAppId(standardUrl); String? appId = await tryInferringAppId(standardUrl);
String host = Uri.parse(standardUrl).host; String host = Uri.parse(standardUrl).host;
var details = getAPKUrlsFromFDroidPackagesAPIResponse( var details = getAPKUrlsFromFDroidPackagesAPIResponse(
await sourceRequest( await sourceRequest(
'https://$host/api/v1/packages/$appId', additionalSettings), 'https://$host/api/v1/packages/$appId',
'https://$host/repo/$appId', additionalSettings,
standardUrl, ),
name, 'https://$host/repo/$appId',
additionalSettings: additionalSettings); standardUrl,
name,
additionalSettings: additionalSettings,
);
if (!hostChanged) { if (!hostChanged) {
try { try {
var res = await sourceRequest( var res = await sourceRequest(
'https://gitlab.com/fdroid/fdroiddata/-/raw/master/metadata/$appId.yml', 'https://gitlab.com/fdroid/fdroiddata/-/raw/master/metadata/$appId.yml',
additionalSettings); additionalSettings,
);
var lines = res.body.split('\n'); var lines = res.body.split('\n');
var authorLines = lines.where((l) => l.startsWith('AuthorName: ')); var authorLines = lines.where((l) => l.startsWith('AuthorName: '));
if (authorLines.isNotEmpty) { if (authorLines.isNotEmpty) {
details.names.author = details.names.author = authorLines.first
authorLines.first.split(': ').sublist(1).join(': '); .split(': ')
.sublist(1)
.join(': ');
} }
var changelogUrls = lines var changelogUrls = lines
.where((l) => l.startsWith('Changelog: ')) .where((l) => l.startsWith('Changelog: '))
@ -110,9 +126,9 @@ class FDroid extends AppSource {
if ((isGitHub || isGitLab) && if ((isGitHub || isGitLab) &&
(details.changeLog?.indexOf('/blob/') ?? -1) >= 0) { (details.changeLog?.indexOf('/blob/') ?? -1) >= 0) {
details.changeLog = (await sourceRequest( details.changeLog = (await sourceRequest(
details.changeLog!.replaceFirst('/blob/', '/raw/'), details.changeLog!.replaceFirst('/blob/', '/raw/'),
additionalSettings)) additionalSettings,
.body; )).body;
} }
} }
} catch (e) { } catch (e) {
@ -126,10 +142,14 @@ class FDroid extends AppSource {
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
Response res = await sourceRequest( Response res = await sourceRequest(
'https://search.${hosts[0]}/?q=${Uri.encodeQueryComponent(query)}', {}); 'https://search.${hosts[0]}/?q=${Uri.encodeQueryComponent(query)}',
{},
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
Map<String, List<String>> urlsWithDescriptions = {}; Map<String, List<String>> urlsWithDescriptions = {};
parse(res.body).querySelectorAll('.package-header').forEach((e) { parse(res.body).querySelectorAll('.package-header').forEach((e) {
@ -145,7 +165,7 @@ class FDroid extends AppSource {
urlsWithDescriptions[url] = [ urlsWithDescriptions[url] = [
e.querySelector('.package-name')?.text.trim() ?? '', e.querySelector('.package-name')?.text.trim() ?? '',
e.querySelector('.package-summary')?.text.trim() ?? e.querySelector('.package-summary')?.text.trim() ??
tr('noDescription') tr('noDescription'),
]; ];
} }
}); });
@ -156,29 +176,36 @@ class FDroid extends AppSource {
} }
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse( APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
Response res, String apkUrlPrefix, String standardUrl, String sourceName, Response res,
{Map<String, dynamic> additionalSettings = const {}}) { String apkUrlPrefix,
String standardUrl,
String sourceName, {
Map<String, dynamic> additionalSettings = const {},
}) {
var autoSelectHighestVersionCode = var autoSelectHighestVersionCode =
additionalSettings['autoSelectHighestVersionCode'] == true; additionalSettings['autoSelectHighestVersionCode'] == true;
var trySelectingSuggestedVersionCode = var trySelectingSuggestedVersionCode =
additionalSettings['trySelectingSuggestedVersionCode'] == true; additionalSettings['trySelectingSuggestedVersionCode'] == true;
var filterVersionsByRegEx = var filterVersionsByRegEx =
(additionalSettings['filterVersionsByRegEx'] as String?)?.isNotEmpty == (additionalSettings['filterVersionsByRegEx'] as String?)?.isNotEmpty ==
true true
? additionalSettings['filterVersionsByRegEx'] ? additionalSettings['filterVersionsByRegEx']
: null; : null;
var apkFilterRegEx = var apkFilterRegEx =
(additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == true (additionalSettings['apkFilterRegEx'] as String?)?.isNotEmpty == true
? additionalSettings['apkFilterRegEx'] ? additionalSettings['apkFilterRegEx']
: null; : null;
if (res.statusCode == 200) { if (res.statusCode == 200) {
var response = jsonDecode(res.body); var response = jsonDecode(res.body);
List<dynamic> releases = response['packages'] ?? []; List<dynamic> releases = response['packages'] ?? [];
if (apkFilterRegEx != null) { if (apkFilterRegEx != null) {
releases = releases.where((rel) { releases = releases.where((rel) {
String apk = '${apkUrlPrefix}_${rel['versionCode']}.apk'; String apk = '${apkUrlPrefix}_${rel['versionCode']}.apk';
return filterApks([MapEntry(apk, apk)], apkFilterRegEx, false) return filterApks(
.isNotEmpty; [MapEntry(apk, apk)],
apkFilterRegEx,
false,
).isNotEmpty;
}).toList(); }).toList();
} }
if (releases.isEmpty) { if (releases.isEmpty) {
@ -191,8 +218,10 @@ class FDroid extends AppSource {
if (trySelectingSuggestedVersionCode && if (trySelectingSuggestedVersionCode &&
response['suggestedVersionCode'] != null && response['suggestedVersionCode'] != null &&
filterVersionsByRegEx == null) { filterVersionsByRegEx == null) {
var suggestedReleases = releases.where((element) => var suggestedReleases = releases.where(
element['versionCode'] == response['suggestedVersionCode']); (element) =>
element['versionCode'] == response['suggestedVersionCode'],
);
if (suggestedReleases.isNotEmpty) { if (suggestedReleases.isNotEmpty) {
releaseChoices = suggestedReleases; releaseChoices = suggestedReleases;
version = suggestedReleases.first['versionName']; version = suggestedReleases.first['versionName'];
@ -203,8 +232,9 @@ class FDroid extends AppSource {
version = null; version = null;
releaseChoices = []; releaseChoices = [];
for (var i = 0; i < releases.length; i++) { for (var i = 0; i < releases.length; i++) {
if (RegExp(filterVersionsByRegEx!) if (RegExp(
.hasMatch(releases[i]['versionName'])) { filterVersionsByRegEx!,
).hasMatch(releases[i]['versionName'])) {
version = releases[i]['versionName']; version = releases[i]['versionName'];
} }
} }
@ -219,8 +249,9 @@ class FDroid extends AppSource {
} }
// If a suggested release was not already picked, pick all those with the selected version // If a suggested release was not already picked, pick all those with the selected version
if (releaseChoices.isEmpty) { if (releaseChoices.isEmpty) {
releaseChoices = releaseChoices = releases.where(
releases.where((element) => element['versionName'] == version); (element) => element['versionName'] == version,
);
} }
// For the remaining releases, use the toggles to auto-select one if possible // For the remaining releases, use the toggles to auto-select one if possible
if (releaseChoices.length > 1) { if (releaseChoices.length > 1) {
@ -228,8 +259,10 @@ class FDroid extends AppSource {
releaseChoices = [releaseChoices.first]; releaseChoices = [releaseChoices.first];
} else if (trySelectingSuggestedVersionCode && } else if (trySelectingSuggestedVersionCode &&
response['suggestedVersionCode'] != null) { response['suggestedVersionCode'] != null) {
var suggestedReleases = releaseChoices.where((element) => var suggestedReleases = releaseChoices.where(
element['versionCode'] == response['suggestedVersionCode']); (element) =>
element['versionCode'] == response['suggestedVersionCode'],
);
if (suggestedReleases.isNotEmpty) { if (suggestedReleases.isNotEmpty) {
releaseChoices = suggestedReleases; releaseChoices = suggestedReleases;
} }
@ -241,8 +274,11 @@ class FDroid extends AppSource {
List<String> apkUrls = releaseChoices List<String> apkUrls = releaseChoices
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList(); .toList();
return APKDetails(version, getApkUrlsFromUrls(apkUrls.toSet().toList()), return APKDetails(
AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last)); version,
getApkUrlsFromUrls(apkUrls.toSet().toList()),
AppNames(sourceName, Uri.parse(standardUrl).pathSegments.last),
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -15,15 +15,20 @@ class FDroidRepo extends AppSource {
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormTextField('appIdOrName', GeneratedFormTextField(
label: tr('appIdOrName'), 'appIdOrName',
hint: tr('reposHaveMultipleApps'), label: tr('appIdOrName'),
required: true) hint: tr('reposHaveMultipleApps'),
required: true,
),
], ],
[ [
GeneratedFormSwitch('pickHighestVersionCode', GeneratedFormSwitch(
label: tr('pickHighestVersionCode'), defaultValue: false) 'pickHighestVersionCode',
] label: tr('pickHighestVersionCode'),
defaultValue: false,
),
],
]; ];
} }
@ -54,8 +59,10 @@ class FDroidRepo extends AppSource {
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
String? url = querySettings['url']; String? url = querySettings['url'];
if (url == null) { if (url == null) {
throw NoReleasesError(); throw NoReleasesError();
@ -73,11 +80,8 @@ class FDroidRepo extends AppSource {
appId.contains(query) || appId.contains(query) ||
appName.contains(query) || appName.contains(query) ||
appDesc.contains(query)) { appDesc.contains(query)) {
results[ results['${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}?appId=$appId'] =
'${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}?appId=$appId'] = [ [appName, appDesc];
appName,
appDesc
];
} }
}); });
return results; return results;
@ -90,21 +94,21 @@ class FDroidRepo extends AppSource {
void runOnAddAppInputChange(String userInput) { void runOnAddAppInputChange(String userInput) {
additionalSourceAppSpecificSettingFormItems = additionalSourceAppSpecificSettingFormItems =
additionalSourceAppSpecificSettingFormItems.map((row) { additionalSourceAppSpecificSettingFormItems.map((row) {
row = row.map((item) { row = row.map((item) {
if (item.key == 'appIdOrName') { if (item.key == 'appIdOrName') {
try { try {
var appId = Uri.parse(userInput).queryParameters['appId']; var appId = Uri.parse(userInput).queryParameters['appId'];
if (appId != null && item is GeneratedFormTextField) { if (appId != null && item is GeneratedFormTextField) {
item.required = false; item.required = false;
}
} catch (e) {
//
}
} }
} catch (e) { return item;
// }).toList();
} return row;
} }).toList();
return item;
}).toList();
return row;
}).toList();
} }
@override @override
@ -119,8 +123,11 @@ class FDroidRepo extends AppSource {
if (appId != null) { if (appId != null) {
app.url = uri app.url = uri
.replace( .replace(
queryParameters: Map.fromEntries( queryParameters: Map.fromEntries([
[...uri.queryParameters.entries, MapEntry('appId', appId)])) ...uri.queryParameters.entries,
MapEntry('appId', appId),
]),
)
.toString(); .toString();
app.additionalSettings['appIdOrName'] = appId; app.additionalSettings['appIdOrName'] = appId;
app.id = appId; app.id = appId;
@ -133,8 +140,9 @@ class FDroidRepo extends AppSource {
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
var res = await sourceRequest( var res = await sourceRequest(
'$url${url.endsWith('/index.xml') ? '' : '/index.xml'}', '$url${url.endsWith('/index.xml') ? '' : '/index.xml'}',
additionalSettings); additionalSettings,
);
if (res.statusCode != 200) { if (res.statusCode != 200) {
var base = url.endsWith('/index.xml') var base = url.endsWith('/index.xml')
? url.split('/').reversed.toList().sublist(1).reversed.join('/') ? url.split('/').reversed.toList().sublist(1).reversed.join('/')
@ -142,7 +150,9 @@ class FDroidRepo extends AppSource {
res = await sourceRequest('$base/repo/index.xml', additionalSettings); res = await sourceRequest('$base/repo/index.xml', additionalSettings);
if (res.statusCode != 200) { if (res.statusCode != 200) {
res = await sourceRequest( res = await sourceRequest(
'$base/fdroid/repo/index.xml', additionalSettings); '$base/fdroid/repo/index.xml',
additionalSettings,
);
} }
} }
return res; return res;
@ -164,8 +174,10 @@ class FDroidRepo extends AppSource {
throw NoReleasesError(); throw NoReleasesError();
} }
additionalSettings['appIdOrName'] = appIdOrName; additionalSettings['appIdOrName'] = appIdOrName;
var res = var res = await sourceRequestWithURLVariants(
await sourceRequestWithURLVariants(standardUrl, additionalSettings); standardUrl,
additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var body = parse(res.body); var body = parse(res.body);
var foundApps = body.querySelectorAll('application').where((element) { var foundApps = body.querySelectorAll('application').where((element) {
@ -202,24 +214,32 @@ class FDroidRepo extends AppSource {
throw NoVersionError(); throw NoVersionError();
} }
var latestVersionReleases = releases var latestVersionReleases = releases
.where((element) => .where(
element.querySelector('version')?.innerHtml == latestVersion && (element) =>
element.querySelector('apkname') != null) element.querySelector('version')?.innerHtml == latestVersion &&
element.querySelector('apkname') != null,
)
.toList(); .toList();
if (latestVersionReleases.length > 1 && pickHighestVersionCode) { if (latestVersionReleases.length > 1 && pickHighestVersionCode) {
latestVersionReleases.sort((e1, e2) { latestVersionReleases.sort((e1, e2) {
return int.parse(e2.querySelector('versioncode')!.innerHtml) return int.parse(
.compareTo(int.parse(e1.querySelector('versioncode')!.innerHtml)); e2.querySelector('versioncode')!.innerHtml,
).compareTo(int.parse(e1.querySelector('versioncode')!.innerHtml));
}); });
latestVersionReleases = [latestVersionReleases[0]]; latestVersionReleases = [latestVersionReleases[0]];
} }
List<String> apkUrls = latestVersionReleases List<String> apkUrls = latestVersionReleases
.map((e) => .map(
'${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}/${e.querySelector('apkname')!.innerHtml}') (e) =>
'${res.request!.url.toString().split('/').reversed.toList().sublist(1).reversed.join('/')}/${e.querySelector('apkname')!.innerHtml}',
)
.toList(); .toList();
return APKDetails(latestVersion, getApkUrlsFromUrls(apkUrls), return APKDetails(
AppNames(authorName, appName), latestVersion,
releaseDate: releaseDate); getApkUrlsFromUrls(apkUrls),
AppNames(authorName, appName),
releaseDate: releaseDate,
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -19,145 +19,185 @@ class GitHub extends AppSource {
showReleaseDateAsVersionToggle = true; showReleaseDateAsVersionToggle = true;
sourceConfigSettingFormItems = [ sourceConfigSettingFormItems = [
GeneratedFormTextField('github-creds', GeneratedFormTextField(
label: tr('githubPATLabel'), 'github-creds',
password: true, label: tr('githubPATLabel'),
required: false, password: true,
belowWidgets: [ required: false,
const SizedBox( belowWidgets: [
height: 4, const SizedBox(height: 4),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
mode: LaunchMode.externalApplication,
);
},
child: Text(
tr('about'),
style: const TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
),
), ),
GestureDetector( ),
onTap: () { const SizedBox(height: 4),
launchUrlString( ],
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token', ),
mode: LaunchMode.externalApplication);
},
child: Text(
tr('about'),
style: const TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
)),
const SizedBox(
height: 4,
),
])
]; ];
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSwitch('includePrereleases', GeneratedFormSwitch(
label: tr('includePrereleases'), defaultValue: false) 'includePrereleases',
label: tr('includePrereleases'),
defaultValue: false,
),
], ],
[ [
GeneratedFormSwitch('fallbackToOlderReleases', GeneratedFormSwitch(
label: tr('fallbackToOlderReleases'), defaultValue: true) 'fallbackToOlderReleases',
label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
], ],
[ [
GeneratedFormTextField('filterReleaseTitlesByRegEx', GeneratedFormTextField(
label: tr('filterReleaseTitlesByRegEx'), 'filterReleaseTitlesByRegEx',
required: false, label: tr('filterReleaseTitlesByRegEx'),
additionalValidators: [ required: false,
(value) { additionalValidators: [
return regExValidator(value); (value) {
} return regExValidator(value);
]) },
],
),
], ],
[ [
GeneratedFormTextField('filterReleaseNotesByRegEx', GeneratedFormTextField(
label: tr('filterReleaseNotesByRegEx'), 'filterReleaseNotesByRegEx',
required: false, label: tr('filterReleaseNotesByRegEx'),
additionalValidators: [ required: false,
(value) { additionalValidators: [
return regExValidator(value); (value) {
} return regExValidator(value);
]) },
],
),
], ],
[GeneratedFormSwitch('verifyLatestTag', label: tr('verifyLatestTag'))], [GeneratedFormSwitch('verifyLatestTag', label: tr('verifyLatestTag'))],
[ [
GeneratedFormDropdown( GeneratedFormDropdown(
'sortMethodChoice', 'sortMethodChoice',
[ [
MapEntry('date', tr('releaseDate')), MapEntry('date', tr('releaseDate')),
MapEntry('smartname', tr('smartname')), MapEntry('smartname', tr('smartname')),
MapEntry('none', tr('none')), MapEntry('none', tr('none')),
MapEntry('smartname-datefallback', MapEntry(
'${tr('smartname')} x ${tr('releaseDate')}'), 'smartname-datefallback',
MapEntry('name', tr('name')), '${tr('smartname')} x ${tr('releaseDate')}',
], ),
label: tr('sortMethod'), MapEntry('name', tr('name')),
defaultValue: 'date') ],
label: tr('sortMethod'),
defaultValue: 'date',
),
], ],
[ [
GeneratedFormSwitch('useLatestAssetDateAsReleaseDate', GeneratedFormSwitch(
label: tr('useLatestAssetDateAsReleaseDate'), defaultValue: false) 'useLatestAssetDateAsReleaseDate',
label: tr('useLatestAssetDateAsReleaseDate'),
defaultValue: false,
),
], ],
[ [
GeneratedFormSwitch('releaseTitleAsVersion', GeneratedFormSwitch(
label: tr('releaseTitleAsVersion'), defaultValue: false) 'releaseTitleAsVersion',
] label: tr('releaseTitleAsVersion'),
defaultValue: false,
),
],
]; ];
canSearch = true; canSearch = true;
searchQuerySettingFormItems = [ searchQuerySettingFormItems = [
GeneratedFormTextField('minStarCount', GeneratedFormTextField(
label: tr('minStarCount'), 'minStarCount',
defaultValue: '0', label: tr('minStarCount'),
additionalValidators: [ defaultValue: '0',
(value) { additionalValidators: [
try { (value) {
int.parse(value ?? '0'); try {
} catch (e) { int.parse(value ?? '0');
return tr('invalidInput'); } catch (e) {
} return tr('invalidInput');
return null;
} }
]) return null;
},
],
),
]; ];
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
const possibleBuildGradleLocations = [ const possibleBuildGradleLocations = [
'/app/build.gradle', '/app/build.gradle',
'android/app/build.gradle', 'android/app/build.gradle',
'src/app/build.gradle' 'src/app/build.gradle',
]; ];
for (var path in possibleBuildGradleLocations) { for (var path in possibleBuildGradleLocations) {
try { try {
var res = await sourceRequest( var res = await sourceRequest(
'${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/contents/$path', '${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/contents/$path',
additionalSettings); additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
try { try {
var body = jsonDecode(res.body); var body = jsonDecode(res.body);
var trimmedLines = utf8 var trimmedLines = utf8
.decode(base64 .decode(
.decode(body['content'].toString().split('\n').join(''))) base64.decode(
body['content'].toString().split('\n').join(''),
),
)
.split('\n') .split('\n')
.map((e) => e.trim()); .map((e) => e.trim());
var appIds = trimmedLines.where((l) => var appIds = trimmedLines.where(
l.startsWith('applicationId "') || (l) =>
l.startsWith('applicationId \'')); l.startsWith('applicationId "') ||
appIds = appIds.map((appId) => appId l.startsWith('applicationId \''),
.split(appId.startsWith('applicationId "') ? '"' : '\'')[1]); );
appIds = appIds.map((appId) { appIds = appIds.map(
if (appId.startsWith('\${') && appId.endsWith('}')) { (appId) => appId.split(
appId = trimmedLines appId.startsWith('applicationId "') ? '"' : '\'',
.where((l) => l.startsWith( )[1],
'def ${appId.substring(2, appId.length - 1)}')) );
.first; appIds = appIds
appId = appId.split(appId.contains('"') ? '"' : '\'')[1]; .map((appId) {
} if (appId.startsWith('\${') && appId.endsWith('}')) {
return appId; appId = trimmedLines
}).where((appId) => appId.isNotEmpty); .where(
(l) => l.startsWith(
'def ${appId.substring(2, appId.length - 1)}',
),
)
.first;
appId = appId.split(appId.contains('"') ? '"' : '\'')[1];
}
return appId;
})
.where((appId) => appId.isNotEmpty);
if (appIds.length == 1) { if (appIds.length == 1) {
return appIds.first; return appIds.first;
} }
} catch (err) { } catch (err) {
LogsProvider().add( LogsProvider().add(
'Error parsing build.gradle from ${res.request!.url.toString()}: ${err.toString()}'); 'Error parsing build.gradle from ${res.request!.url.toString()}: ${err.toString()}',
);
} }
} }
} catch (err) { } catch (err) {
@ -170,8 +210,9 @@ class GitHub extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -181,8 +222,9 @@ class GitHub extends AppSource {
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
var token = await getTokenIfAny(additionalSettings); var token = await getTokenIfAny(additionalSettings);
var headers = <String, String>{}; var headers = <String, String>{};
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
@ -201,14 +243,17 @@ class GitHub extends AppSource {
Future<String?> getTokenIfAny(Map<String, dynamic> additionalSettings) async { Future<String?> getTokenIfAny(Map<String, dynamic> additionalSettings) async {
SettingsProvider settingsProvider = SettingsProvider(); SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings(); await settingsProvider.initializeSettings();
var sourceConfig = var sourceConfig = await getSourceConfigValues(
await getSourceConfigValues(additionalSettings, settingsProvider); additionalSettings,
settingsProvider,
);
String? creds = sourceConfig['github-creds']; String? creds = sourceConfig['github-creds'];
if (creds != null) { if (creds != null) {
var userNameEndIndex = creds.indexOf(':'); var userNameEndIndex = creds.indexOf(':');
if (userNameEndIndex > 0) { if (userNameEndIndex > 0) {
creds = creds.substring( creds = creds.substring(
userNameEndIndex + 1); // For old username-included token inputs userNameEndIndex + 1,
); // For old username-included token inputs
} }
return creds; return creds;
} else { } else {
@ -228,31 +273,36 @@ class GitHub extends AppSource {
'https://api.${hosts[0]}'; 'https://api.${hosts[0]}';
Future<String> convertStandardUrlToAPIUrl( Future<String> convertStandardUrlToAPIUrl(
String standardUrl, Map<String, dynamic> additionalSettings) async => String standardUrl,
Map<String, dynamic> additionalSettings,
) async =>
'${await getAPIHost(additionalSettings)}/repos${standardUrl.substring('https://${hosts[0]}'.length)}'; '${await getAPIHost(additionalSettings)}/repos${standardUrl.substring('https://${hosts[0]}'.length)}';
@override @override
String? changeLogPageFromStandardUrl(String standardUrl) => String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/releases'; '$standardUrl/releases';
Future<APKDetails> getLatestAPKDetailsCommon(String requestUrl, Future<APKDetails> getLatestAPKDetailsCommon(
String standardUrl, Map<String, dynamic> additionalSettings, String requestUrl,
{Function(Response)? onHttpErrorCode}) async { String standardUrl,
Map<String, dynamic> additionalSettings, {
Function(Response)? onHttpErrorCode,
}) async {
bool includePrereleases = additionalSettings['includePrereleases'] == true; bool includePrereleases = additionalSettings['includePrereleases'] == true;
bool fallbackToOlderReleases = bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true; additionalSettings['fallbackToOlderReleases'] == true;
String? regexFilter = String? regexFilter =
(additionalSettings['filterReleaseTitlesByRegEx'] as String?) (additionalSettings['filterReleaseTitlesByRegEx'] as String?)
?.isNotEmpty == ?.isNotEmpty ==
true true
? additionalSettings['filterReleaseTitlesByRegEx'] ? additionalSettings['filterReleaseTitlesByRegEx']
: null; : null;
String? regexNotesFilter = String? regexNotesFilter =
(additionalSettings['filterReleaseNotesByRegEx'] as String?) (additionalSettings['filterReleaseNotesByRegEx'] as String?)
?.isNotEmpty == ?.isNotEmpty ==
true true
? additionalSettings['filterReleaseNotesByRegEx'] ? additionalSettings['filterReleaseNotesByRegEx']
: null; : null;
bool verifyLatestTag = additionalSettings['verifyLatestTag'] == true; bool verifyLatestTag = additionalSettings['verifyLatestTag'] == true;
bool useLatestAssetDateAsReleaseDate = bool useLatestAssetDateAsReleaseDate =
additionalSettings['useLatestAssetDateAsReleaseDate'] == true; additionalSettings['useLatestAssetDateAsReleaseDate'] == true;
@ -262,8 +312,9 @@ class GitHub extends AppSource {
if (verifyLatestTag) { if (verifyLatestTag) {
var temp = requestUrl.split('?'); var temp = requestUrl.split('?');
Response res = await sourceRequest( Response res = await sourceRequest(
'${temp[0]}/latest${temp.length > 1 ? '?${temp.sublist(1).join('?')}' : ''}', '${temp[0]}/latest${temp.length > 1 ? '?${temp.sublist(1).join('?')}' : ''}',
additionalSettings); additionalSettings,
);
if (res.statusCode != 200) { if (res.statusCode != 200) {
if (onHttpErrorCode != null) { if (onHttpErrorCode != null) {
onHttpErrorCode(res); onHttpErrorCode(res);
@ -278,8 +329,10 @@ class GitHub extends AppSource {
if (latestRelease != null) { if (latestRelease != null) {
var latestTag = latestRelease['tag_name'] ?? latestRelease['name']; var latestTag = latestRelease['tag_name'] ?? latestRelease['name'];
if (releases if (releases
.where((element) => .where(
(element['tag_name'] ?? element['name']) == latestTag) (element) =>
(element['tag_name'] ?? element['name']) == latestTag,
)
.isEmpty) { .isEmpty) {
releases = [latestRelease, ...releases]; releases = [latestRelease, ...releases];
} }
@ -299,10 +352,10 @@ class GitHub extends AppSource {
DateTime? getPublishDateFromRelease(dynamic rel) => DateTime? getPublishDateFromRelease(dynamic rel) =>
rel?['published_at'] != null rel?['published_at'] != null
? DateTime.parse(rel['published_at']) ? DateTime.parse(rel['published_at'])
: rel?['commit']?['created'] != null : rel?['commit']?['created'] != null
? DateTime.parse(rel['commit']['created']) ? DateTime.parse(rel['commit']['created'])
: null; : null;
DateTime? getNewestAssetDateFromRelease(dynamic rel) { DateTime? getNewestAssetDateFromRelease(dynamic rel) {
var allAssets = rel['assets'] as List<dynamic>?; var allAssets = rel['assets'] as List<dynamic>?;
var filteredAssets = rel['filteredAssets'] as List<dynamic>?; var filteredAssets = rel['filteredAssets'] as List<dynamic>?;
@ -323,8 +376,8 @@ class GitHub extends AppSource {
DateTime? getReleaseDateFromRelease(dynamic rel, bool useAssetDate) => DateTime? getReleaseDateFromRelease(dynamic rel, bool useAssetDate) =>
!useAssetDate !useAssetDate
? getPublishDateFromRelease(rel) ? getPublishDateFromRelease(rel)
: getNewestAssetDateFromRelease(rel); : getNewestAssetDateFromRelease(rel);
if (sortMethod == 'none') { if (sortMethod == 'none') {
releases = releases.reversed.toList(); releases = releases.reversed.toList();
@ -340,29 +393,40 @@ class GitHub extends AppSource {
} else { } else {
var nameA = a['tag_name'] ?? a['name']; var nameA = a['tag_name'] ?? a['name'];
var nameB = b['tag_name'] ?? b['name']; var nameB = b['tag_name'] ?? b['name'];
var stdFormats = findStandardFormatsForVersion(nameA, false) var stdFormats = findStandardFormatsForVersion(
.intersection(findStandardFormatsForVersion(nameB, false)); nameA,
false,
).intersection(findStandardFormatsForVersion(nameB, false));
if (sortMethod == 'date' || if (sortMethod == 'date' ||
(sortMethod == 'smartname-datefallback' && (sortMethod == 'smartname-datefallback' &&
stdFormats.isEmpty)) { stdFormats.isEmpty)) {
return (getReleaseDateFromRelease( return (getReleaseDateFromRelease(
a, useLatestAssetDateAsReleaseDate) ?? a,
useLatestAssetDateAsReleaseDate,
) ??
DateTime(1)) DateTime(1))
.compareTo(getReleaseDateFromRelease( .compareTo(
b, useLatestAssetDateAsReleaseDate) ?? getReleaseDateFromRelease(
DateTime(0)); b,
useLatestAssetDateAsReleaseDate,
) ??
DateTime(0),
);
} else { } else {
if (sortMethod != 'name' && stdFormats.isNotEmpty) { if (sortMethod != 'name' && stdFormats.isNotEmpty) {
var reg = RegExp(stdFormats.last); var reg = RegExp(stdFormats.last);
var matchA = reg.firstMatch(nameA); var matchA = reg.firstMatch(nameA);
var matchB = reg.firstMatch(nameB); var matchB = reg.firstMatch(nameB);
return compareAlphaNumeric( return compareAlphaNumeric(
(nameA as String).substring(matchA!.start, matchA.end), (nameA as String).substring(matchA!.start, matchA.end),
(nameB as String).substring(matchB!.start, matchB.end)); (nameB as String).substring(matchB!.start, matchB.end),
);
} else { } else {
// 'name' // 'name'
return compareAlphaNumeric( return compareAlphaNumeric(
(nameA as String), (nameB as String)); (nameA as String),
(nameB as String),
);
} }
} }
} }
@ -374,9 +438,11 @@ class GitHub extends AppSource {
latestRelease != latestRelease !=
(releases[releases.length - 1]['tag_name'] ?? (releases[releases.length - 1]['tag_name'] ??
releases[0]['name'])) { releases[0]['name'])) {
var ind = releases.indexWhere((element) => var ind = releases.indexWhere(
(latestRelease['tag_name'] ?? latestRelease['name']) == (element) =>
(element['tag_name'] ?? element['name'])); (latestRelease['tag_name'] ?? latestRelease['name']) ==
(element['tag_name'] ?? element['name']),
);
if (ind >= 0) { if (ind >= 0) {
releases.add(releases.removeAt(ind)); releases.add(releases.removeAt(ind));
} }
@ -404,8 +470,9 @@ class GitHub extends AppSource {
continue; continue;
} }
if (regexNotesFilter != null && if (regexNotesFilter != null &&
!RegExp(regexNotesFilter) !RegExp(
.hasMatch(((releases[i]['body'] as String?) ?? '').trim())) { regexNotesFilter,
).hasMatch(((releases[i]['body'] as String?) ?? '').trim())) {
continue; continue;
} }
var allAssetsWithUrls = findReleaseAssetUrls(releases[i]); var allAssetsWithUrls = findReleaseAssetUrls(releases[i]);
@ -413,24 +480,31 @@ class GitHub extends AppSource {
.map((e) => e['final_url'] as MapEntry<String, String>) .map((e) => e['final_url'] as MapEntry<String, String>)
.toList(); .toList();
var apkAssetsWithUrls = allAssetsWithUrls var apkAssetsWithUrls = allAssetsWithUrls
.where((element) => .where(
(element['final_url'] as MapEntry<String, String>) (element) => (element['final_url'] as MapEntry<String, String>)
.key .key
.toLowerCase() .toLowerCase()
.endsWith('.apk')) .endsWith('.apk'),
)
.toList(); .toList();
var filteredApkUrls = filterApks( var filteredApkUrls = filterApks(
apkAssetsWithUrls apkAssetsWithUrls
.map((e) => e['final_url'] as MapEntry<String, String>) .map((e) => e['final_url'] as MapEntry<String, String>)
.toList(), .toList(),
additionalSettings['apkFilterRegEx'], additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter']); additionalSettings['invertAPKFilter'],
);
var filteredApks = apkAssetsWithUrls var filteredApks = apkAssetsWithUrls
.where((e) => filteredApkUrls .where(
.where((e2) => (e) => filteredApkUrls
e2.key == (e['final_url'] as MapEntry<String, String>).key) .where(
.isNotEmpty) (e2) =>
e2.key ==
(e['final_url'] as MapEntry<String, String>).key,
)
.isNotEmpty,
)
.toList(); .toList();
if (filteredApks.isEmpty && additionalSettings['trackOnly'] != true) { if (filteredApks.isEmpty && additionalSettings['trackOnly'] != true) {
@ -441,17 +515,23 @@ class GitHub extends AppSource {
targetRelease['filteredAssets'] = filteredApks; targetRelease['filteredAssets'] = filteredApks;
targetRelease['version'] = targetRelease['version'] =
additionalSettings['releaseTitleAsVersion'] == true additionalSettings['releaseTitleAsVersion'] == true
? nameToFilter ? nameToFilter
: targetRelease['tag_name'] ?? targetRelease['name']; : targetRelease['tag_name'] ?? targetRelease['name'];
if (targetRelease['tarball_url'] != null) { if (targetRelease['tarball_url'] != null) {
allAssetUrls.add(MapEntry( allAssetUrls.add(
MapEntry(
(targetRelease['version'] ?? 'source') + '.tar.gz', (targetRelease['version'] ?? 'source') + '.tar.gz',
targetRelease['tarball_url'])); targetRelease['tarball_url'],
),
);
} }
if (targetRelease['zipball_url'] != null) { if (targetRelease['zipball_url'] != null) {
allAssetUrls.add(MapEntry( allAssetUrls.add(
MapEntry(
(targetRelease['version'] ?? 'source') + '.zip', (targetRelease['version'] ?? 'source') + '.zip',
targetRelease['zipball_url'])); targetRelease['zipball_url'],
),
);
} }
targetRelease['allAssetUrls'] = allAssetUrls; targetRelease['allAssetUrls'] = allAssetUrls;
break; break;
@ -462,19 +542,22 @@ class GitHub extends AppSource {
String? version = targetRelease['version']; String? version = targetRelease['version'];
DateTime? releaseDate = getReleaseDateFromRelease( DateTime? releaseDate = getReleaseDateFromRelease(
targetRelease, useLatestAssetDateAsReleaseDate); targetRelease,
useLatestAssetDateAsReleaseDate,
);
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
var changeLog = (targetRelease['body'] ?? '').toString(); var changeLog = (targetRelease['body'] ?? '').toString();
return APKDetails( return APKDetails(
version, version,
targetRelease['apkUrls'] as List<MapEntry<String, String>>, targetRelease['apkUrls'] as List<MapEntry<String, String>>,
getAppNames(standardUrl), getAppNames(standardUrl),
releaseDate: releaseDate, releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog, changeLog: changeLog.isEmpty ? null : changeLog,
allAssetUrls: allAssetUrls:
targetRelease['allAssetUrls'] as List<MapEntry<String, String>>); targetRelease['allAssetUrls'] as List<MapEntry<String, String>>,
);
} else { } else {
if (onHttpErrorCode != null) { if (onHttpErrorCode != null) {
onHttpErrorCode(res); onHttpErrorCode(res);
@ -483,20 +566,27 @@ class GitHub extends AppSource {
} }
} }
getLatestAPKDetailsCommon2( Future<APKDetails> getLatestAPKDetailsCommon2(
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
Future<String> Function(bool) reqUrlGenerator, Future<String> Function(bool) reqUrlGenerator,
dynamic Function(Response)? onHttpErrorCode) async { dynamic Function(Response)? onHttpErrorCode,
) async {
try { try {
return await getLatestAPKDetailsCommon( return await getLatestAPKDetailsCommon(
await reqUrlGenerator(false), standardUrl, additionalSettings, await reqUrlGenerator(false),
onHttpErrorCode: onHttpErrorCode); standardUrl,
additionalSettings,
onHttpErrorCode: onHttpErrorCode,
);
} catch (err) { } catch (err) {
if (err is NoReleasesError && additionalSettings['trackOnly'] == true) { if (err is NoReleasesError && additionalSettings['trackOnly'] == true) {
return await getLatestAPKDetailsCommon( return await getLatestAPKDetailsCommon(
await reqUrlGenerator(true), standardUrl, additionalSettings, await reqUrlGenerator(true),
onHttpErrorCode: onHttpErrorCode); standardUrl,
additionalSettings,
onHttpErrorCode: onHttpErrorCode,
);
} else { } else {
rethrow; rethrow;
} }
@ -508,12 +598,16 @@ class GitHub extends AppSource {
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
return await getLatestAPKDetailsCommon2(standardUrl, additionalSettings, return await getLatestAPKDetailsCommon2(
(bool useTagUrl) async { standardUrl,
return '${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100'; additionalSettings,
}, (Response res) { (bool useTagUrl) async {
rateLimitErrorCheck(res); return '${await convertStandardUrlToAPIUrl(standardUrl, additionalSettings)}/${useTagUrl ? 'tags' : 'releases'}?per_page=100';
}); },
(Response res) {
rateLimitErrorCheck(res);
},
);
} }
AppNames getAppNames(String standardUrl) { AppNames getAppNames(String standardUrl) {
@ -523,9 +617,12 @@ class GitHub extends AppSource {
} }
Future<Map<String, List<String>>> searchCommon( Future<Map<String, List<String>>> searchCommon(
String query, String requestUrl, String rootProp, String query,
{Function(Response)? onHttpErrorCode, String requestUrl,
Map<String, dynamic> querySettings = const {}}) async { String rootProp, {
Function(Response)? onHttpErrorCode,
Map<String, dynamic> querySettings = const {},
}) async {
Response res = await sourceRequest(requestUrl, {}); Response res = await sourceRequest(requestUrl, {});
if (res.statusCode == 200) { if (res.statusCode == 200) {
int minStarCount = querySettings['minStarCount'] != null int minStarCount = querySettings['minStarCount'] != null
@ -540,8 +637,8 @@ class GitHub extends AppSource {
((e['archived'] == true ? '[ARCHIVED] ' : '') + ((e['archived'] == true ? '[ARCHIVED] ' : '') +
(e['description'] != null (e['description'] != null
? e['description'] as String ? e['description'] as String
: tr('noDescription'))) : tr('noDescription'))),
] ],
}); });
} }
} }
@ -555,22 +652,27 @@ class GitHub extends AppSource {
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
return searchCommon( return searchCommon(
query, query,
'${await getAPIHost({})}/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100', '${await getAPIHost({})}/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100',
'items', onHttpErrorCode: (Response res) { 'items',
rateLimitErrorCheck(res); onHttpErrorCode: (Response res) {
}, querySettings: querySettings); rateLimitErrorCheck(res);
},
querySettings: querySettings,
);
} }
rateLimitErrorCheck(Response res) { void rateLimitErrorCheck(Response res) {
if (res.headers['x-ratelimit-remaining'] == '0') { if (res.headers['x-ratelimit-remaining'] == '0') {
throw RateLimitError( throw RateLimitError(
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / (int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000)
60000000) .round(),
.round()); );
} }
} }
} }

View File

@ -18,36 +18,41 @@ class GitLab extends AppSource {
showReleaseDateAsVersionToggle = true; showReleaseDateAsVersionToggle = true;
sourceConfigSettingFormItems = [ sourceConfigSettingFormItems = [
GeneratedFormTextField('gitlab-creds', GeneratedFormTextField(
label: tr('gitlabPATLabel'), 'gitlab-creds',
password: true, label: tr('gitlabPATLabel'),
required: false, password: true,
belowWidgets: [ required: false,
const SizedBox( belowWidgets: [
height: 4, const SizedBox(height: 4),
GestureDetector(
onTap: () {
launchUrlString(
'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token',
mode: LaunchMode.externalApplication,
);
},
child: Text(
tr('about'),
style: const TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
),
), ),
GestureDetector( ),
onTap: () { const SizedBox(height: 4),
launchUrlString( ],
'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token', ),
mode: LaunchMode.externalApplication);
},
child: Text(
tr('about'),
style: const TextStyle(
decoration: TextDecoration.underline, fontSize: 12),
)),
const SizedBox(
height: 4,
)
])
]; ];
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSwitch('fallbackToOlderReleases', GeneratedFormSwitch(
label: tr('fallbackToOlderReleases'), defaultValue: true) 'fallbackToOlderReleases',
] label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
]; ];
} }
@ -55,11 +60,13 @@ class GitLab extends AppSource {
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
var urlSegments = url.split('/'); var urlSegments = url.split('/');
var cutOffIndex = urlSegments.indexWhere((s) => s == '-'); var cutOffIndex = urlSegments.indexWhere((s) => s == '-');
url = url = urlSegments
urlSegments.sublist(0, cutOffIndex <= 0 ? null : cutOffIndex).join('/'); .sublist(0, cutOffIndex <= 0 ? null : cutOffIndex)
.join('/');
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+(/[^((\b/\b)|(\b/-/\b))]+){1,20}', '^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+(/[^((\b/\b)|(\b/-/\b))]+){1,20}',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -70,15 +77,19 @@ class GitLab extends AppSource {
Future<String?> getPATIfAny(Map<String, dynamic> additionalSettings) async { Future<String?> getPATIfAny(Map<String, dynamic> additionalSettings) async {
SettingsProvider settingsProvider = SettingsProvider(); SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings(); await settingsProvider.initializeSettings();
var sourceConfig = var sourceConfig = await getSourceConfigValues(
await getSourceConfigValues(additionalSettings, settingsProvider); additionalSettings,
settingsProvider,
);
String? creds = sourceConfig['gitlab-creds']; String? creds = sourceConfig['gitlab-creds'];
return creds != null && creds.isNotEmpty ? creds : null; return creds != null && creds.isNotEmpty ? creds : null;
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
var url = var url =
'https://${hosts[0]}/api/v4/projects?search=${Uri.encodeQueryComponent(query)}'; 'https://${hosts[0]}/api/v4/projects?search=${Uri.encodeQueryComponent(query)}';
var res = await sourceRequest(url, {}); var res = await sourceRequest(url, {});
@ -90,7 +101,7 @@ class GitLab extends AppSource {
for (var element in json) { for (var element in json) {
results['https://${hosts[0]}/${element['path_with_namespace']}'] = [ results['https://${hosts[0]}/${element['path_with_namespace']}'] = [
element['name_with_namespace'], element['name_with_namespace'],
element['description'] ?? tr('noDescription') element['description'] ?? tr('noDescription'),
]; ];
} }
return results; return results;
@ -102,8 +113,9 @@ class GitLab extends AppSource {
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
// Change headers to pacify, e.g. cloudflare protection // Change headers to pacify, e.g. cloudflare protection
// Related to: (#1397, #1389, #1384, #1382, #1381, #1380, #1359, #854, #785, #697) // Related to: (#1397, #1389, #1384, #1382, #1381, #1380, #1359, #854, #785, #697)
var headers = <String, String>{}; var headers = <String, String>{};
@ -116,8 +128,11 @@ class GitLab extends AppSource {
} }
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl, String standardUrl, Future<String> apkUrlPrefetchModifier(
Map<String, dynamic> additionalSettings) async { String apkUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
String? PAT = await getPATIfAny(hostChanged ? additionalSettings : {}); String? PAT = await getPATIfAny(hostChanged ? additionalSettings : {});
String optionalAuth = (PAT != null) ? 'private_token=$PAT' : ''; String optionalAuth = (PAT != null) ? 'private_token=$PAT' : '';
return '$apkUrl${(Uri.parse(apkUrl).query.isEmpty ? '?' : '&')}$optionalAuth'; return '$apkUrl${(Uri.parse(apkUrl).query.isEmpty ? '?' : '&')}$optionalAuth';
@ -139,8 +154,9 @@ class GitLab extends AppSource {
// Get project ID // Get project ID
Response res0 = await sourceRequest( Response res0 = await sourceRequest(
'https://${hosts[0]}/api/v4/projects/$projectUriComponent?$optionalAuth', 'https://${hosts[0]}/api/v4/projects/$projectUriComponent?$optionalAuth',
additionalSettings); additionalSettings,
);
if (res0.statusCode != 200) { if (res0.statusCode != 200) {
throw getObtainiumHttpError(res0); throw getObtainiumHttpError(res0);
} }
@ -151,8 +167,9 @@ class GitLab extends AppSource {
// Request data from REST API // Request data from REST API
Response res = await sourceRequest( Response res = await sourceRequest(
'https://${hosts[0]}/api/v4/projects/$projectUriComponent/${trackOnly ? 'repository/tags' : 'releases'}?$optionalAuth', 'https://${hosts[0]}/api/v4/projects/$projectUriComponent/${trackOnly ? 'repository/tags' : 'releases'}?$optionalAuth',
additionalSettings); additionalSettings,
);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }
@ -166,11 +183,13 @@ class GitLab extends AppSource {
var url = (e['direct_asset_url'] ?? e['url'] ?? '') as String; var url = (e['direct_asset_url'] ?? e['url'] ?? '') as String;
var parsedUrl = url.isNotEmpty ? Uri.parse(url) : null; var parsedUrl = url.isNotEmpty ? Uri.parse(url) : null;
return MapEntry( return MapEntry(
(e['name'] ?? (e['name'] ??
(parsedUrl != null && parsedUrl.pathSegments.isNotEmpty (parsedUrl != null && parsedUrl.pathSegments.isNotEmpty
? parsedUrl.pathSegments.last ? parsedUrl.pathSegments.last
: 'unknown')) as String, : 'unknown'))
(e['direct_asset_url'] ?? e['url'] ?? '') as String); as String,
(e['direct_asset_url'] ?? e['url'] ?? '') as String,
);
}) })
.where((s) => s.key.isNotEmpty) .where((s) => s.key.isNotEmpty)
.toList(); .toList();
@ -193,11 +212,15 @@ class GitLab extends AppSource {
} }
var releaseDateString = var releaseDateString =
e['released_at'] ?? e['created_at'] ?? e['commit']?['created_at']; e['released_at'] ?? e['created_at'] ?? e['commit']?['created_at'];
DateTime? releaseDate = DateTime? releaseDate = releaseDateString != null
releaseDateString != null ? DateTime.parse(releaseDateString) : null; ? DateTime.parse(releaseDateString)
return APKDetails(e['tag_name'] ?? e['name'], apkUrls.entries.toList(), : null;
AppNames(names.author, names.name.split('/').last), return APKDetails(
releaseDate: releaseDate); e['tag_name'] ?? e['name'],
apkUrls.entries.toList(),
AppNames(names.author, names.name.split('/').last),
releaseDate: releaseDate,
);
}); });
if (apkDetailsList.isEmpty) { if (apkDetailsList.isEmpty) {
throw NoReleasesError(); throw NoReleasesError();
@ -208,8 +231,9 @@ class GitLab extends AppSource {
bool fallbackToOlderReleases = bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true; additionalSettings['fallbackToOlderReleases'] == true;
if (finalResult.apkUrls.isEmpty && fallbackToOlderReleases && !trackOnly) { if (finalResult.apkUrls.isEmpty && fallbackToOlderReleases && !trackOnly) {
apkDetailsList = apkDetailsList = apkDetailsList
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList(); .where((e) => e.apkUrls.isNotEmpty)
.toList();
finalResult = apkDetailsList.first; finalResult = apkDetailsList.first;
} }
@ -218,10 +242,13 @@ class GitLab extends AppSource {
} }
finalResult.apkUrls = finalResult.apkUrls.map((apkUrl) { finalResult.apkUrls = finalResult.apkUrls.map((apkUrl) {
if (RegExp('^$standardUrl/-/jobs/[0-9]+/artifacts/file/[^/]+') if (RegExp(
.hasMatch(apkUrl.value)) { '^$standardUrl/-/jobs/[0-9]+/artifacts/file/[^/]+',
).hasMatch(apkUrl.value)) {
return MapEntry( return MapEntry(
apkUrl.key, apkUrl.value.replaceFirst('/file/', '/raw/')); apkUrl.key,
apkUrl.value.replaceFirst('/file/', '/raw/'),
);
} else { } else {
return apkUrl; return apkUrl;
} }

View File

@ -9,6 +9,13 @@ import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) { String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) {
try {
if (Uri.parse(ambiguousUrl).isAbsolute) {
return ambiguousUrl; // #2315
}
} catch (e) {
//
}
return referenceAbsoluteUrl.resolve(ambiguousUrl).toString(); return referenceAbsoluteUrl.resolve(ambiguousUrl).toString();
} }
@ -93,28 +100,37 @@ bool _isNumeric(String s) {
return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57; return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57;
} }
List<MapEntry<String, String>> getLinksInLines(String lines) => RegExp( List<MapEntry<String, String>> getLinksInLines(String lines) =>
r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?') RegExp(
.allMatches(lines) r'(http|ftp|https)://([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?',
.map((match) => )
MapEntry(match.group(0)!, match.group(0)?.split('/').last ?? '')) .allMatches(lines)
.toList(); .map(
(match) =>
MapEntry(match.group(0)!, match.group(0)?.split('/').last ?? ''),
)
.toList();
// Given an HTTP response, grab some links according to the common additional settings // Given an HTTP response, grab some links according to the common additional settings
// (those that apply to intermediate and final steps) // (those that apply to intermediate and final steps)
Future<List<MapEntry<String, String>>> grabLinksCommon( Future<List<MapEntry<String, String>>> grabLinksCommon(
Response res, Map<String, dynamic> additionalSettings) async { Response res,
Map<String, dynamic> additionalSettings,
) async {
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }
var html = parse(res.body); var html = parse(res.body);
List<MapEntry<String, String>> allLinks = html List<MapEntry<String, String>> allLinks = html
.querySelectorAll('a') .querySelectorAll('a')
.map((element) => MapEntry( .map(
(element) => MapEntry(
element.attributes['href'] ?? '', element.attributes['href'] ?? '',
element.text.isNotEmpty element.text.isNotEmpty
? element.text ? element.text
: (element.attributes['href'] ?? '').split('/').last)) : (element.attributes['href'] ?? '').split('/').last,
),
)
.where((element) => element.key.isNotEmpty) .where((element) => element.key.isNotEmpty)
.map((e) => MapEntry(ensureAbsoluteUrl(e.key, res.request!.url), e.value)) .map((e) => MapEntry(ensureAbsoluteUrl(e.key, res.request!.url), e.value))
.toList(); .toList();
@ -127,9 +143,13 @@ Future<List<MapEntry<String, String>>> grabLinksCommon(
var jsonStrings = collectAllStringsFromJSONObject(jsonDecode(res.body)); var jsonStrings = collectAllStringsFromJSONObject(jsonDecode(res.body));
allLinks = getLinksInLines(jsonStrings.join('\n')); allLinks = getLinksInLines(jsonStrings.join('\n'));
if (allLinks.isEmpty) { if (allLinks.isEmpty) {
allLinks = getLinksInLines(jsonStrings.map((l) { allLinks = getLinksInLines(
return ensureAbsoluteUrl(l, res.request!.url); jsonStrings
}).join('\n')); .map((l) {
return ensureAbsoluteUrl(l, res.request!.url);
})
.join('\n'),
);
} }
} catch (e) { } catch (e) {
// //
@ -158,17 +178,20 @@ Future<List<MapEntry<String, String>>> grabLinksCommon(
} catch (e) { } catch (e) {
// Some links may not have valid encoding // Some links may not have valid encoding
} }
return Uri.parse(filterLinkByText ? element.value : link) return Uri.parse(
.path filterLinkByText ? element.value : link,
.toLowerCase() ).path.toLowerCase().endsWith('.apk');
.endsWith('.apk');
}).toList(); }).toList();
} }
if (!skipSort) { if (!skipSort) {
links.sort((a, b) => additionalSettings['sortByLastLinkSegment'] == true links.sort(
? compareAlphaNumeric(a.key.split('/').where((e) => e.isNotEmpty).last, (a, b) => additionalSettings['sortByLastLinkSegment'] == true
b.key.split('/').where((e) => e.isNotEmpty).last) ? compareAlphaNumeric(
: compareAlphaNumeric(a.key, b.key)); a.key.split('/').where((e) => e.isNotEmpty).last,
b.key.split('/').where((e) => e.isNotEmpty).last,
)
: compareAlphaNumeric(a.key, b.key),
);
} }
if (additionalSettings['reverseSort'] == true) { if (additionalSettings['reverseSort'] == true) {
links = links.reversed.toList(); links = links.reversed.toList();
@ -194,102 +217,119 @@ class HTML extends AppSource {
var finalStepFormitems = [ var finalStepFormitems = [
[ [
GeneratedFormTextField('customLinkFilterRegex', GeneratedFormTextField(
label: tr('customLinkFilterRegex'), 'customLinkFilterRegex',
hint: 'download/(.*/)?(android|apk|mobile)', label: tr('customLinkFilterRegex'),
required: false, hint: 'download/(.*/)?(android|apk|mobile)',
additionalValidators: [ required: false,
(value) { additionalValidators: [
return regExValidator(value); (value) {
} return regExValidator(value);
]) },
],
),
], ],
[ [
GeneratedFormSwitch('versionExtractWholePage', GeneratedFormSwitch(
label: tr('versionExtractWholePage')) 'versionExtractWholePage',
] label: tr('versionExtractWholePage'),
),
],
]; ];
var commonFormItems = [ var commonFormItems = [
[GeneratedFormSwitch('filterByLinkText', label: tr('filterByLinkText'))], [GeneratedFormSwitch('filterByLinkText', label: tr('filterByLinkText'))],
[GeneratedFormSwitch('skipSort', label: tr('skipSort'))], [GeneratedFormSwitch('skipSort', label: tr('skipSort'))],
[GeneratedFormSwitch('reverseSort', label: tr('takeFirstLink'))], [GeneratedFormSwitch('reverseSort', label: tr('takeFirstLink'))],
[ [
GeneratedFormSwitch('sortByLastLinkSegment', GeneratedFormSwitch(
label: tr('sortByLastLinkSegment')) 'sortByLastLinkSegment',
label: tr('sortByLastLinkSegment'),
),
], ],
]; ];
var intermediateFormItems = [ var intermediateFormItems = [
[ [
GeneratedFormTextField('customLinkFilterRegex', GeneratedFormTextField(
label: tr('intermediateLinkRegex'), 'customLinkFilterRegex',
hint: '([0-9]+.)*[0-9]+/\$', label: tr('intermediateLinkRegex'),
required: true, hint: '([0-9]+.)*[0-9]+/\$',
additionalValidators: [(value) => regExValidator(value)]) required: true,
additionalValidators: [(value) => regExValidator(value)],
),
], ],
[ [
GeneratedFormSwitch('autoLinkFilterByArch', GeneratedFormSwitch(
label: tr('autoLinkFilterByArch'), defaultValue: false) 'autoLinkFilterByArch',
label: tr('autoLinkFilterByArch'),
defaultValue: false,
),
], ],
]; ];
HTML() { HTML() {
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSubForm( GeneratedFormSubForm('intermediateLink', [
'intermediateLink', [...intermediateFormItems, ...commonFormItems], ...intermediateFormItems,
label: tr('intermediateLink')) ...commonFormItems,
], label: tr('intermediateLink')),
], ],
finalStepFormitems[0], finalStepFormitems[0],
...commonFormItems, ...commonFormItems,
...finalStepFormitems.sublist(1), ...finalStepFormitems.sublist(1),
[ [
GeneratedFormSubForm( GeneratedFormSubForm(
'requestHeader', 'requestHeader',
[
[ [
[ GeneratedFormTextField(
GeneratedFormTextField('requestHeader', 'requestHeader',
label: tr('requestHeader'), label: tr('requestHeader'),
required: false, required: false,
additionalValidators: [ additionalValidators: [
(value) { (value) {
if ((value ?? 'empty:valid') if ((value ?? 'empty:valid')
.split(':') .split(':')
.map((e) => e.trim()) .map((e) => e.trim())
.where((e) => e.isNotEmpty) .where((e) => e.isNotEmpty)
.length < .length <
2) { 2) {
return tr('invalidInput'); return tr('invalidInput');
} }
return null; return null;
} },
]) ],
] ),
], ],
label: tr('requestHeader'), ],
defaultValue: [ label: tr('requestHeader'),
{ defaultValue: [
'requestHeader': {
'User-Agent: Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' 'requestHeader':
} 'User-Agent: Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
]) },
],
),
], ],
[ [
GeneratedFormDropdown( GeneratedFormDropdown(
'defaultPseudoVersioningMethod', 'defaultPseudoVersioningMethod',
[ [
MapEntry('partialAPKHash', tr('partialAPKHash')), MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('APKLinkHash', tr('APKLinkHash')), MapEntry('APKLinkHash', tr('APKLinkHash')),
MapEntry('ETag', 'ETag') MapEntry('ETag', 'ETag'),
], ],
label: tr('defaultPseudoVersioningMethod'), label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash') defaultValue: 'partialAPKHash',
] ),
],
]; ];
} }
@override @override
Future<Map<String, String>?> getRequestHeaders( Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings, {
{bool forAPKDownload = false}) async { bool forAPKDownload = false,
}) async {
if (additionalSettings.isNotEmpty) { if (additionalSettings.isNotEmpty) {
if (additionalSettings['requestHeader']?.isNotEmpty != true) { if (additionalSettings['requestHeader']?.isNotEmpty != true) {
additionalSettings['requestHeader'] = []; additionalSettings['requestHeader'] = [];
@ -329,8 +369,9 @@ class HTML extends AppSource {
.toList(); .toList();
for (int i = 0; i < (additionalSettings['intermediateLink'].length); i++) { for (int i = 0; i < (additionalSettings['intermediateLink'].length); i++) {
var intLinks = await grabLinksCommon( var intLinks = await grabLinksCommon(
await sourceRequest(currentUrl, additionalSettings), await sourceRequest(currentUrl, additionalSettings),
additionalSettings['intermediateLink'][i]); additionalSettings['intermediateLink'][i],
);
if (intLinks.isEmpty) { if (intLinks.isEmpty) {
throw NoReleasesError(note: currentUrl); throw NoReleasesError(note: currentUrl);
} else { } else {
@ -346,11 +387,17 @@ class HTML extends AppSource {
String versionExtractionWholePageString = currentUrl; String versionExtractionWholePageString = currentUrl;
if (additionalSettings['directAPKLink'] != true) { if (additionalSettings['directAPKLink'] != true) {
Response res = await sourceRequest(currentUrl, additionalSettings); Response res = await sourceRequest(currentUrl, additionalSettings);
versionExtractionWholePageString = versionExtractionWholePageString = res.body
res.body.split('\r\n').join('\n').split('\n').join('\\n'); .split('\r\n')
.join('\n')
.split('\n')
.join('\\n');
links = await grabLinksCommon(res, additionalSettings); links = await grabLinksCommon(res, additionalSettings);
links = filterApks(links, additionalSettings['apkFilterRegEx'], links = filterApks(
additionalSettings['invertAPKFilter']); links,
additionalSettings['apkFilterRegEx'],
additionalSettings['invertAPKFilter'],
);
if (links.isEmpty) { if (links.isEmpty) {
throw NoReleasesError(note: currentUrl); throw NoReleasesError(note: currentUrl);
} }
@ -366,37 +413,45 @@ class HTML extends AppSource {
} }
String? version; String? version;
version = extractVersion( version = extractVersion(
additionalSettings['versionExtractionRegEx'] as String?, additionalSettings['versionExtractionRegEx'] as String?,
additionalSettings['matchGroupToUse'] as String?, additionalSettings['matchGroupToUse'] as String?,
additionalSettings['versionExtractWholePage'] == true additionalSettings['versionExtractWholePage'] == true
? versionExtractionWholePageString ? versionExtractionWholePageString
: relDecoded); : relDecoded,
var apkReqHeaders = );
await getRequestHeaders(additionalSettings, forAPKDownload: true); var apkReqHeaders = await getRequestHeaders(
additionalSettings,
forAPKDownload: true,
);
if (version == null && if (version == null &&
additionalSettings['defaultPseudoVersioningMethod'] == 'ETag') { additionalSettings['defaultPseudoVersioningMethod'] == 'ETag') {
version = await checkETagHeader(rel, version = await checkETagHeader(
headers: apkReqHeaders, rel,
allowInsecure: additionalSettings['allowInsecure'] == true); headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true,
);
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
} }
version ??= version ??=
additionalSettings['defaultPseudoVersioningMethod'] == 'APKLinkHash' additionalSettings['defaultPseudoVersioningMethod'] == 'APKLinkHash'
? rel.hashCode.toString() ? rel.hashCode.toString()
: (await checkPartialDownloadHashDynamic(rel, : (await checkPartialDownloadHashDynamic(
headers: apkReqHeaders, rel,
allowInsecure: additionalSettings['allowInsecure'] == true)) headers: apkReqHeaders,
.toString(); allowInsecure: additionalSettings['allowInsecure'] == true,
)).toString();
return APKDetails( return APKDetails(
version, version,
[rel].map((e) { [rel].map((e) {
var uri = Uri.parse(e); var uri = Uri.parse(e);
var fileName = var fileName = uri.pathSegments.isNotEmpty
uri.pathSegments.isNotEmpty ? uri.pathSegments.last : uri.origin; ? uri.pathSegments.last
return MapEntry('${e.hashCode}-$fileName', e); : uri.origin;
}).toList(), return MapEntry('${e.hashCode}-$fileName', e);
AppNames(uri.host, tr('app'))); }).toList(),
AppNames(uri.host, tr('app')),
);
} }
} }

View File

@ -14,8 +14,9 @@ class HuaweiAppGallery extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}(/#)?/(app|appdl)/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}(/#)?/(app|appdl)/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -23,13 +24,18 @@ class HuaweiAppGallery extends AppSource {
return match.group(0)!; return match.group(0)!;
} }
getDlUrl(String standardUrl) => String getDlUrl(String standardUrl) =>
'https://${hosts[0].replaceAll('appgallery.huawei', 'appgallery.cloud.huawei')}/appdl/${standardUrl.split('/').last}'; 'https://${hosts[0].replaceAll('appgallery.huawei', 'appgallery.cloud.huawei')}/appdl/${standardUrl.split('/').last}';
requestAppdlRedirect( Future<Response> requestAppdlRedirect(
String dlUrl, Map<String, dynamic> additionalSettings) async { String dlUrl,
Response res = Map<String, dynamic> additionalSettings,
await sourceRequest(dlUrl, additionalSettings, followRedirects: false); ) async {
Response res = await sourceRequest(
dlUrl,
additionalSettings,
followRedirects: false,
);
if (res.statusCode == 200 || if (res.statusCode == 200 ||
res.statusCode == 302 || res.statusCode == 302 ||
res.statusCode == 304) { res.statusCode == 304) {
@ -39,7 +45,7 @@ class HuaweiAppGallery extends AppSource {
} }
} }
appIdFromRedirectDlUrl(String redirectDlUrl) { String appIdFromRedirectDlUrl(String redirectDlUrl) {
var parts = redirectDlUrl var parts = redirectDlUrl
.split('?')[0] .split('?')[0]
.split('/') .split('/')
@ -53,8 +59,10 @@ class HuaweiAppGallery extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
String dlUrl = getDlUrl(standardUrl); String dlUrl = getDlUrl(standardUrl);
Response res = await requestAppdlRedirect(dlUrl, additionalSettings); Response res = await requestAppdlRedirect(dlUrl, additionalSettings);
return res.headers['location'] != null return res.headers['location'] != null
@ -76,8 +84,11 @@ class HuaweiAppGallery extends AppSource {
if (appId.isEmpty) { if (appId.isEmpty) {
throw NoReleasesError(); throw NoReleasesError();
} }
var relDateStr = var relDateStr = res.headers['location']
res.headers['location']?.split('?')[0].split('.').reversed.toList()[1]; ?.split('?')[0]
.split('.')
.reversed
.toList()[1];
if (relDateStr == null || relDateStr.length != 10) { if (relDateStr == null || relDateStr.length != 10) {
throw NoVersionError(); throw NoVersionError();
} }
@ -88,10 +99,15 @@ class HuaweiAppGallery extends AppSource {
relDateStrAdj.insert((i + i ~/ 2 - 1), '-'); relDateStrAdj.insert((i + i ~/ 2 - 1), '-');
i += 2; i += 2;
} }
var relDate = var relDate = DateFormat(
DateFormat('yy-MM-dd-HH-mm', 'en_US').parse(relDateStrAdj.join('')); 'yy-MM-dd-HH-mm',
'en_US',
).parse(relDateStrAdj.join(''));
return APKDetails( return APKDetails(
relDateStr, [MapEntry('$appId.apk', dlUrl)], AppNames(name, appId), relDateStr,
releaseDate: relDate); [MapEntry('$appId.apk', dlUrl)],
AppNames(name, appId),
releaseDate: relDate,
);
} }
} }

View File

@ -16,13 +16,15 @@ class IzzyOnDroid extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegExA = RegExp( RegExp standardUrlRegExA = RegExp(
'^https?://android.${getSourceRegex(hosts)}/repo/apk/[^/]+', '^https?://android.${getSourceRegex(hosts)}/repo/apk/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExA.firstMatch(url); RegExpMatch? match = standardUrlRegExA.firstMatch(url);
if (match == null) { if (match == null) {
RegExp standardUrlRegExB = RegExp( RegExp standardUrlRegExB = RegExp(
'^https?://apt.${getSourceRegex(hosts)}/fdroid/index/apk/[^/]+', '^https?://apt.${getSourceRegex(hosts)}/fdroid/index/apk/[^/]+',
caseSensitive: false); caseSensitive: false,
);
match = standardUrlRegExB.firstMatch(url); match = standardUrlRegExB.firstMatch(url);
} }
if (match == null) { if (match == null) {
@ -32,8 +34,10 @@ class IzzyOnDroid extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return fd.tryInferringAppId(standardUrl); return fd.tryInferringAppId(standardUrl);
} }
@ -44,12 +48,14 @@ class IzzyOnDroid extends AppSource {
) async { ) async {
String? appId = await tryInferringAppId(standardUrl); String? appId = await tryInferringAppId(standardUrl);
return fd.getAPKUrlsFromFDroidPackagesAPIResponse( return fd.getAPKUrlsFromFDroidPackagesAPIResponse(
await sourceRequest( await sourceRequest(
'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId', 'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId',
additionalSettings), additionalSettings,
'https://android.izzysoft.de/frepo/$appId', ),
standardUrl, 'https://android.izzysoft.de/frepo/$appId',
name, standardUrl,
additionalSettings: additionalSettings); name,
additionalSettings: additionalSettings,
);
} }
} }

View File

@ -31,14 +31,17 @@ class Jenkins extends AppSource {
) async { ) async {
standardUrl = trimJobUrl(standardUrl); standardUrl = trimJobUrl(standardUrl);
Response res = await sourceRequest( Response res = await sourceRequest(
'$standardUrl/lastSuccessfulBuild/api/json', additionalSettings); '$standardUrl/lastSuccessfulBuild/api/json',
additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var json = jsonDecode(res.body); var json = jsonDecode(res.body);
var releaseDate = json['timestamp'] == null var releaseDate = json['timestamp'] == null
? null ? null
: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int); : DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int);
var version = var version = json['number'] == null
json['number'] == null ? null : (json['number'] as int).toString(); ? null
: (json['number'] as int).toString();
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
@ -51,16 +54,21 @@ class Jenkins extends AppSource {
return path == null return path == null
? const MapEntry<String, String>('', '') ? const MapEntry<String, String>('', '')
: MapEntry<String, String>( : MapEntry<String, String>(
(e['fileName'] ?? e['relativePath']) as String, path); (e['fileName'] ?? e['relativePath']) as String,
path,
);
}) })
.where((url) => .where(
url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk')) (url) =>
url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk'),
)
.toList(); .toList();
return APKDetails( return APKDetails(
version, version,
apkUrls, apkUrls,
releaseDate: releaseDate, releaseDate: releaseDate,
AppNames(Uri.parse(standardUrl).host, standardUrl.split('/').last)); AppNames(Uri.parse(standardUrl).host, standardUrl.split('/').last),
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -12,8 +12,9 @@ class Mullvad extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}', '^https?://(www\\.)?${getSourceRegex(hosts)}',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -31,7 +32,9 @@ class Mullvad extends AppSource {
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
Response res = await sourceRequest( Response res = await sourceRequest(
'$standardUrl/en/download/android', additionalSettings); '$standardUrl/en/download/android',
additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var versions = parse(res.body) var versions = parse(res.body)
.querySelectorAll('p') .querySelectorAll('p')
@ -53,17 +56,18 @@ class Mullvad extends AppSource {
String? changeLog; String? changeLog;
try { try {
changeLog = (await GitHub().getLatestAPKDetails( changeLog = (await GitHub().getLatestAPKDetails(
'https://github.com/mullvad/mullvadvpn-app', 'https://github.com/mullvad/mullvadvpn-app',
{'fallbackToOlderReleases': true})) {'fallbackToOlderReleases': true},
.changeLog; )).changeLog;
} catch (e) { } catch (e) {
// Ignore // Ignore
} }
return APKDetails( return APKDetails(
versions[0], versions[0],
getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']), getApkUrlsFromUrls(['https://mullvad.net/download/app/apk/latest']),
AppNames(name, 'Mullvad-VPN'), AppNames(name, 'Mullvad-VPN'),
changeLog: changeLog); changeLog: changeLog,
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -12,8 +12,9 @@ class NeutronCode extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/downloads/file/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/downloads/file/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -55,7 +56,7 @@ class NeutronCode extends AppSource {
} }
} }
customDateParse(String dateString) { String? customDateParse(String dateString) {
List<String> parts = dateString.split(' '); List<String> parts = dateString.split(' ');
if (parts.length != 3) { if (parts.length != 3) {
return null; return null;
@ -89,24 +90,31 @@ class NeutronCode extends AppSource {
if (filename == null) { if (filename == null) {
throw NoReleasesError(); throw NoReleasesError();
} }
var version = var version = http
http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml; .querySelector('.pd-version-txt')
?.nextElementSibling
?.innerHtml;
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
String? apkUrl = 'https://${hosts[0]}/download/$filename'; String? apkUrl = 'https://${hosts[0]}/download/$filename';
var dateStringOriginal = var dateStringOriginal = http
http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml; .querySelector('.pd-date-txt')
?.nextElementSibling
?.innerHtml;
var dateString = dateStringOriginal != null var dateString = dateStringOriginal != null
? (customDateParse(dateStringOriginal)) ? (customDateParse(dateStringOriginal))
: null; : null;
var changeLogElements = http.querySelectorAll('.pd-fdesc p'); var changeLogElements = http.querySelectorAll('.pd-fdesc p');
return APKDetails(version, getApkUrlsFromUrls([apkUrl]), return APKDetails(
AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last), version,
releaseDate: dateString != null ? DateTime.parse(dateString) : null, getApkUrlsFromUrls([apkUrl]),
changeLog: changeLogElements.isNotEmpty AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last),
? changeLogElements.last.innerHtml releaseDate: dateString != null ? DateTime.parse(dateString) : null,
: null); changeLog: changeLogElements.isNotEmpty
? changeLogElements.last.innerHtml
: null,
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -18,8 +18,9 @@ class RuStore extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/catalog/app/+[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/catalog/app/+[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -28,16 +29,18 @@ class RuStore extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
Future<String> decodeString(String str) async { Future<String> decodeString(String str) async {
try { try {
return (await CharsetDetector.autoDecode( return (await CharsetDetector.autoDecode(
Uint8List.fromList(str.codeUnits))) Uint8List.fromList(str.codeUnits),
.string; )).string;
} catch (e) { } catch (e) {
return str; return str;
} }
@ -50,8 +53,9 @@ class RuStore extends AppSource {
) async { ) async {
String? appId = await tryInferringAppId(standardUrl); String? appId = await tryInferringAppId(standardUrl);
Response res0 = await sourceRequest( Response res0 = await sourceRequest(
'https://backapi.rustore.ru/applicationData/overallInfo/$appId', 'https://backapi.rustore.ru/applicationData/overallInfo/$appId',
additionalSettings); additionalSettings,
);
if (res0.statusCode != 200) { if (res0.statusCode != 200) {
throw getObtainiumHttpError(res0); throw getObtainiumHttpError(res0);
} }
@ -74,10 +78,11 @@ class RuStore extends AppSource {
} }
Response res1 = await sourceRequest( Response res1 = await sourceRequest(
'https://backapi.rustore.ru/applicationData/download-link', 'https://backapi.rustore.ru/applicationData/download-link',
additionalSettings, additionalSettings,
followRedirects: false, followRedirects: false,
postBody: {"appId": appDetails['appId'], "firstInstall": true}); postBody: {"appId": appDetails['appId'], "firstInstall": true},
);
var downloadDetails = jsonDecode(res1.body)['body']; var downloadDetails = jsonDecode(res1.body)['body'];
if (res1.statusCode != 200 || downloadDetails['apkUrl'] == null) { if (res1.statusCode != 200 || downloadDetails['apkUrl'] == null) {
throw NoAPKError(); throw NoAPKError();
@ -88,13 +93,16 @@ class RuStore extends AppSource {
changeLog = changeLog != null ? await decodeString(changeLog) : null; changeLog = changeLog != null ? await decodeString(changeLog) : null;
return APKDetails( return APKDetails(
version, version,
getApkUrlsFromUrls([ getApkUrlsFromUrls([
(downloadDetails['apkUrl'] as String) (downloadDetails['apkUrl'] as String).replaceAll(
.replaceAll(RegExp('\\.zip\$'), '.apk') RegExp('\\.zip\$'),
]), '.apk',
AppNames(author, appName), ),
releaseDate: relDate, ]),
changeLog: changeLog); AppNames(author, appName),
releaseDate: relDate,
changeLog: changeLog,
);
} }
} }

View File

@ -11,23 +11,27 @@ class SourceForge extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
var sourceRegex = getSourceRegex(hosts); var sourceRegex = getSourceRegex(hosts);
RegExp standardUrlRegExC = RegExp standardUrlRegExC = RegExp(
RegExp('^https?://(www\\.)?$sourceRegex/p/.+', caseSensitive: false); '^https?://(www\\.)?$sourceRegex/p/.+',
caseSensitive: false,
);
RegExpMatch? match = standardUrlRegExC.firstMatch(url); RegExpMatch? match = standardUrlRegExC.firstMatch(url);
if (match != null) { if (match != null) {
url = url =
'https://${Uri.parse(match.group(0)!).host}/projects/${url.substring(Uri.parse(match.group(0)!).host.length + '/projects/'.length + 1)}'; 'https://${Uri.parse(match.group(0)!).host}/projects/${url.substring(Uri.parse(match.group(0)!).host.length + '/projects/'.length + 1)}';
} }
RegExp standardUrlRegExB = RegExp( RegExp standardUrlRegExB = RegExp(
'^https?://(www\\.)?$sourceRegex/projects/[^/]+', '^https?://(www\\.)?$sourceRegex/projects/[^/]+',
caseSensitive: false); caseSensitive: false,
);
match = standardUrlRegExB.firstMatch(url); match = standardUrlRegExB.firstMatch(url);
if (match != null && match.group(0) == url) { if (match != null && match.group(0) == url) {
url = '$url/files'; url = '$url/files';
} }
RegExp standardUrlRegExA = RegExp( RegExp standardUrlRegExA = RegExp(
'^https?://(www\\.)?$sourceRegex/projects/[^/]+/files(/.+)?', '^https?://(www\\.)?$sourceRegex/projects/[^/]+/files(/.+)?',
caseSensitive: false); caseSensitive: false,
);
match = standardUrlRegExA.firstMatch(url); match = standardUrlRegExA.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -46,8 +50,9 @@ class SourceForge extends AppSource {
standardUri = Uri.parse(standardUrl); standardUri = Uri.parse(standardUrl);
} }
Response res = await sourceRequest( Response res = await sourceRequest(
'${standardUri.origin}/${standardUri.pathSegments.sublist(0, 2).join('/')}/rss?path=/', '${standardUri.origin}/${standardUri.pathSegments.sublist(0, 2).join('/')}/rss?path=/',
additionalSettings); additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var parsedHtml = parse(res.body); var parsedHtml = parse(res.body);
var allDownloadLinks = parsedHtml var allDownloadLinks = parsedHtml
@ -74,9 +79,10 @@ class SourceForge extends AppSource {
if (version != null) { if (version != null) {
try { try {
var extractedVersion = extractVersion( var extractedVersion = extractVersion(
additionalSettings['versionExtractionRegEx'] as String?, additionalSettings['versionExtractionRegEx'] as String?,
additionalSettings['matchGroupToUse'] as String?, additionalSettings['matchGroupToUse'] as String?,
version); version,
);
if (extractedVersion != null) { if (extractedVersion != null) {
version = extractedVersion; version = extractedVersion;
} }
@ -111,8 +117,11 @@ class SourceForge extends AppSource {
.where((element) => getVersion(element) == version) .where((element) => getVersion(element) == version)
.toList(); .toList();
var segments = standardUrl.split('/'); var segments = standardUrl.split('/');
return APKDetails(version, getApkUrlsFromUrls(apkUrlList), return APKDetails(
AppNames(name, segments[segments.indexOf('files') - 1])); version,
getApkUrlsFromUrls(apkUrlList),
AppNames(name, segments[segments.indexOf('files') - 1]),
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -13,17 +13,21 @@ class SourceHut extends AppSource {
additionalSourceAppSpecificSettingFormItems = [ additionalSourceAppSpecificSettingFormItems = [
[ [
GeneratedFormSwitch('fallbackToOlderReleases', GeneratedFormSwitch(
label: tr('fallbackToOlderReleases'), defaultValue: true) 'fallbackToOlderReleases',
] label: tr('fallbackToOlderReleases'),
defaultValue: true,
),
],
]; ];
} }
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+', '^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+/[^/]+',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -52,8 +56,10 @@ class SourceHut extends AppSource {
String appName = standardUri.pathSegments.last; String appName = standardUri.pathSegments.last;
bool fallbackToOlderReleases = bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true; additionalSettings['fallbackToOlderReleases'] == true;
Response res = Response res = await sourceRequest(
await sourceRequest('$standardUrl/refs/rss.xml', additionalSettings); '$standardUrl/refs/rss.xml',
additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var parsedHtml = parse(res.body); var parsedHtml = parse(res.body);
List<APKDetails> apkDetailsList = []; List<APKDetails> apkDetailsList = [];
@ -63,10 +69,10 @@ class SourceHut extends AppSource {
ind++; ind++;
String releasePage = // querySelector('link') fails for some reason String releasePage = // querySelector('link') fails for some reason
entry entry
.querySelector('guid') // Luckily guid is identical .querySelector('guid') // Luckily guid is identical
?.innerHtml ?.innerHtml
.trim() ?? .trim() ??
''; '';
if (!releasePage.startsWith('$standardUrl/refs')) { if (!releasePage.startsWith('$standardUrl/refs')) {
continue; continue;
} }
@ -84,8 +90,9 @@ class SourceHut extends AppSource {
? DateFormat('E, dd MMM yyyy HH:mm:ss Z').parse(releaseDateString) ? DateFormat('E, dd MMM yyyy HH:mm:ss Z').parse(releaseDateString)
: null; : null;
releaseDate = releaseDateString != null releaseDate = releaseDateString != null
? DateFormat('EEE, dd MMM yyyy HH:mm:ss Z') ? DateFormat(
.parse(releaseDateString) 'EEE, dd MMM yyyy HH:mm:ss Z',
).parse(releaseDateString)
: null; : null;
} catch (e) { } catch (e) {
// ignore // ignore
@ -93,27 +100,35 @@ class SourceHut extends AppSource {
var res2 = await sourceRequest(releasePage, additionalSettings); var res2 = await sourceRequest(releasePage, additionalSettings);
List<MapEntry<String, String>> apkUrls = []; List<MapEntry<String, String>> apkUrls = [];
if (res2.statusCode == 200) { if (res2.statusCode == 200) {
apkUrls = getApkUrlsFromUrls(parse(res2.body) apkUrls = getApkUrlsFromUrls(
.querySelectorAll('a') parse(res2.body)
.map((e) => e.attributes['href'] ?? '') .querySelectorAll('a')
.where((e) => e.toLowerCase().endsWith('.apk')) .map((e) => e.attributes['href'] ?? '')
.map((e) => ensureAbsoluteUrl(e, standardUri)) .where((e) => e.toLowerCase().endsWith('.apk'))
.toList()); .map((e) => ensureAbsoluteUrl(e, standardUri))
.toList(),
);
} }
apkDetailsList.add(APKDetails( apkDetailsList.add(
APKDetails(
version, version,
apkUrls, apkUrls,
AppNames(entry.querySelector('author')?.innerHtml.trim() ?? appName, AppNames(
appName), entry.querySelector('author')?.innerHtml.trim() ?? appName,
releaseDate: releaseDate)); appName,
),
releaseDate: releaseDate,
),
);
} }
if (apkDetailsList.isEmpty) { if (apkDetailsList.isEmpty) {
throw NoReleasesError(); throw NoReleasesError();
} }
if (fallbackToOlderReleases) { if (fallbackToOlderReleases) {
if (additionalSettings['trackOnly'] != true) { if (additionalSettings['trackOnly'] != true) {
apkDetailsList = apkDetailsList = apkDetailsList
apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList(); .where((e) => e.apkUrls.isNotEmpty)
.toList();
} }
if (apkDetailsList.isEmpty) { if (apkDetailsList.isEmpty) {
throw NoReleasesError(); throw NoReleasesError();

View File

@ -20,12 +20,15 @@ class TelegramApp extends AppSource {
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
Response res = Response res = await sourceRequest(
await sourceRequest('https://t.me/s/TAndroidAPK', additionalSettings); 'https://t.me/s/TAndroidAPK',
additionalSettings,
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var http = parse(res.body); var http = parse(res.body);
var messages = var messages = http.querySelectorAll(
http.querySelectorAll('.tgme_widget_message_text.js-message_text'); '.tgme_widget_message_text.js-message_text',
);
var version = messages.isNotEmpty var version = messages.isNotEmpty
? messages.last.innerHtml.split('\n').first.trim().split(' ').first ? messages.last.innerHtml.split('\n').first.trim().split(' ').first
: null; : null;
@ -33,10 +36,9 @@ class TelegramApp extends AppSource {
throw NoVersionError(); throw NoVersionError();
} }
String? apkUrl = 'https://telegram.org/dl/android/apk'; String? apkUrl = 'https://telegram.org/dl/android/apk';
return APKDetails( return APKDetails(version, [
version, MapEntry<String, String>('telegram-$version.apk', apkUrl),
[MapEntry<String, String>('telegram-$version.apk', apkUrl)], ], AppNames('Telegram', 'Telegram'));
AppNames('Telegram', 'Telegram'));
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -15,8 +15,9 @@ class Tencent extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://${getSourceRegex(hosts)}/appdetail/[^/]+', '^https?://${getSourceRegex(hosts)}/appdetail/[^/]+',
caseSensitive: false); caseSensitive: false,
);
var match = standardUrlRegEx.firstMatch(url); var match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -25,8 +26,10 @@ class Tencent extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return Uri.parse(standardUrl).pathSegments.last; return Uri.parse(standardUrl).pathSegments.last;
} }
@ -36,18 +39,16 @@ class Tencent extends AppSource {
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
String appId = (await tryInferringAppId(standardUrl))!; String appId = (await tryInferringAppId(standardUrl))!;
String baseHost = Uri.parse(standardUrl) String baseHost = Uri.parse(
.host standardUrl,
.split('.') ).host.split('.').reversed.toList().sublist(0, 2).reversed.join('.');
.reversed
.toList()
.sublist(0, 2)
.reversed
.join('.');
var res = await sourceRequest( var res = await sourceRequest(
'https://upage.html5.$baseHost/wechat-apkinfo', additionalSettings, 'https://upage.html5.$baseHost/wechat-apkinfo',
followRedirects: false, postBody: {"packagename": appId}); additionalSettings,
followRedirects: false,
postBody: {"packagename": appId},
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
var json = jsonDecode(res.body); var json = jsonDecode(res.body);
@ -64,14 +65,18 @@ class Tencent extends AppSource {
var author = json['app_detail_records'][appId]['app_info']['author']; var author = json['app_detail_records'][appId]['app_info']['author'];
var releaseDate = var releaseDate =
json['app_detail_records'][appId]['app_info']['update_time']; json['app_detail_records'][appId]['app_info']['update_time'];
var apkName = Uri.parse(apkUrl).queryParameters['fsname'] ?? var apkName =
Uri.parse(apkUrl).queryParameters['fsname'] ??
'${appId}_$version.apk'; '${appId}_$version.apk';
return APKDetails( return APKDetails(
version, [MapEntry(apkName, apkUrl)], AppNames(author, appName), version,
releaseDate: releaseDate != null [MapEntry(apkName, apkUrl)],
? DateTime.fromMillisecondsSinceEpoch(releaseDate * 1000) AppNames(author, appName),
: null); releaseDate: releaseDate != null
? DateTime.fromMillisecondsSinceEpoch(releaseDate * 1000)
: null,
);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -3,7 +3,7 @@ import 'package:html/parser.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
parseDateTimeMMMddCommayyyy(String? dateString) { DateTime? parseDateTimeMMMddCommayyyy(String? dateString) {
DateTime? releaseDate; DateTime? releaseDate;
try { try {
releaseDate = dateString != null releaseDate = dateString != null
@ -30,8 +30,9 @@ class Uptodown extends AppSource {
@override @override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) { String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
RegExp standardUrlRegEx = RegExp( RegExp standardUrlRegEx = RegExp(
'^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}', '^https?://([^\\.]+\\.){2,}${getSourceRegex(hosts)}',
caseSensitive: false); caseSensitive: false,
);
RegExpMatch? match = standardUrlRegEx.firstMatch(url); RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) { if (match == null) {
throw InvalidURLError(name); throw InvalidURLError(name);
@ -40,14 +41,20 @@ class Uptodown extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
return (await getAppDetailsFromPage( return (await getAppDetailsFromPage(
standardUrl, additionalSettings))['appId']; standardUrl,
additionalSettings,
))['appId'];
} }
Future<Map<String, String?>> getAppDetailsFromPage( Future<Map<String, String?>> getAppDetailsFromPage(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(standardUrl, additionalSettings); var res = await sourceRequest(standardUrl, additionalSettings);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
@ -63,8 +70,9 @@ class Uptodown extends AppSource {
.toList(); .toList();
String? appId = detailElements.elementAtOrNull(0); String? appId = detailElements.elementAtOrNull(0);
String? dateStr = detailElements.elementAtOrNull(6); String? dateStr = detailElements.elementAtOrNull(6);
String? fileId = String? fileId = html
html.querySelector('#detail-app-name')?.attributes['data-file-id']; .querySelector('#detail-app-name')
?.attributes['data-file-id'];
String? extension = detailElements.elementAtOrNull(7)?.toLowerCase(); String? extension = detailElements.elementAtOrNull(7)?.toLowerCase();
return Map.fromEntries([ return Map.fromEntries([
MapEntry('version', version), MapEntry('version', version),
@ -73,7 +81,7 @@ class Uptodown extends AppSource {
MapEntry('author', author), MapEntry('author', author),
MapEntry('dateStr', dateStr), MapEntry('dateStr', dateStr),
MapEntry('fileId', fileId), MapEntry('fileId', fileId),
MapEntry('extension', extension) MapEntry('extension', extension),
]); ]);
} }
@ -82,8 +90,10 @@ class Uptodown extends AppSource {
String standardUrl, String standardUrl,
Map<String, dynamic> additionalSettings, Map<String, dynamic> additionalSettings,
) async { ) async {
var appDetails = var appDetails = await getAppDetailsFromPage(
await getAppDetailsFromPage(standardUrl, additionalSettings); standardUrl,
additionalSettings,
);
var version = appDetails['version']; var version = appDetails['version'];
var appId = appDetails['appId']; var appId = appDetails['appId'];
var fileId = appDetails['fileId']; var fileId = appDetails['fileId'];
@ -105,21 +115,28 @@ class Uptodown extends AppSource {
if (dateStr != null) { if (dateStr != null) {
relDate = parseDateTimeMMMddCommayyyy(dateStr); relDate = parseDateTimeMMMddCommayyyy(dateStr);
} }
return APKDetails(version, [MapEntry('$appId.$extension', apkUrl)], return APKDetails(
AppNames(author, appName), version,
releaseDate: relDate); [MapEntry('$appId.$extension', apkUrl)],
AppNames(author, appName),
releaseDate: relDate,
);
} }
@override @override
Future<String> apkUrlPrefetchModifier(String apkUrl, String standardUrl, Future<String> apkUrlPrefetchModifier(
Map<String, dynamic> additionalSettings) async { String apkUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var res = await sourceRequest(apkUrl, additionalSettings); var res = await sourceRequest(apkUrl, additionalSettings);
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }
var html = parse(res.body); var html = parse(res.body);
var finalUrlKey = var finalUrlKey = html
html.querySelector('#detail-download-button')?.attributes['data-url']; .querySelector('#detail-download-button')
?.attributes['data-url'];
if (finalUrlKey == null) { if (finalUrlKey == null) {
throw NoAPKError(); throw NoAPKError();
} }

View File

@ -23,15 +23,19 @@ class VivoAppStore extends AppSource {
} }
@override @override
Future<String?> tryInferringAppId(String standardUrl, Future<String?> tryInferringAppId(
{Map<String, dynamic> additionalSettings = const {}}) async { String standardUrl, {
Map<String, dynamic> additionalSettings = const {},
}) async {
var json = await getDetailJson(standardUrl, additionalSettings); var json = await getDetailJson(standardUrl, additionalSettings);
return json['package_name']; return json['package_name'];
} }
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var json = await getDetailJson(standardUrl, additionalSettings); var json = await getDetailJson(standardUrl, additionalSettings);
var appName = json['title_zh'].toString(); var appName = json['title_zh'].toString();
var packageName = json['package_name'].toString(); var packageName = json['package_name'].toString();
@ -42,13 +46,18 @@ class VivoAppStore extends AppSource {
var apkUrl = json['download_url'].toString(); var apkUrl = json['download_url'].toString();
var apkName = '${packageName}_$versionCode.apk'; var apkName = '${packageName}_$versionCode.apk';
return APKDetails( return APKDetails(
versionName, [MapEntry(apkName, apkUrl)], AppNames(developer, appName), versionName,
releaseDate: DateTime.parse(uploadTime)); [MapEntry(apkName, apkUrl)],
AppNames(developer, appName),
releaseDate: DateTime.parse(uploadTime),
);
} }
@override @override
Future<Map<String, List<String>>> search(String query, Future<Map<String, List<String>>> search(
{Map<String, dynamic> querySettings = const {}}) async { String query, {
Map<String, dynamic> querySettings = const {},
}) async {
var apiBaseUrl = var apiBaseUrl =
'https://h5-api.appstore.vivo.com.cn/h5appstore/search/result-list?app_version=2100&page_index=1&apps_per_page=20&target=local&cfrom=2&key='; 'https://h5-api.appstore.vivo.com.cn/h5appstore/search/result-list?app_version=2100&page_index=1&apps_per_page=20&target=local&cfrom=2&key=';
var searchUrl = '$apiBaseUrl${Uri.encodeQueryComponent(query)}'; var searchUrl = '$apiBaseUrl${Uri.encodeQueryComponent(query)}';
@ -65,14 +74,16 @@ class VivoAppStore extends AppSource {
for (var item in (resultsJson as List<dynamic>)) { for (var item in (resultsJson as List<dynamic>)) {
results['$appDetailUrl${item['id']}'] = [ results['$appDetailUrl${item['id']}'] = [
item['title_zh'].toString(), item['title_zh'].toString(),
item['developer'].toString() item['developer'].toString(),
]; ];
} }
return results; return results;
} }
Future<Map<String, dynamic>> getDetailJson( Future<Map<String, dynamic>> getDetailJson(
String standardUrl, Map<String, dynamic> additionalSettings) async { String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
var vivoAppId = parseVivoAppId(standardUrl); var vivoAppId = parseVivoAppId(standardUrl);
var apiBaseUrl = 'https://h5-api.appstore.vivo.com.cn/detail/'; var apiBaseUrl = 'https://h5-api.appstore.vivo.com.cn/detail/';
var params = '?frompage=messageh5&app_version=2100'; var params = '?frompage=messageh5&app_version=2100';

View File

@ -20,8 +20,9 @@ class _CustomAppBarState extends State<CustomAppBar> {
titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
title: Text( title: Text(
widget.title, widget.title,
style: style: TextStyle(
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color), color: Theme.of(context).textTheme.bodyMedium!.color,
),
), ),
), ),
); );

View File

@ -16,11 +16,13 @@ abstract class GeneratedFormItem {
dynamic ensureType(dynamic val); dynamic ensureType(dynamic val);
GeneratedFormItem clone(); GeneratedFormItem clone();
GeneratedFormItem(this.key, GeneratedFormItem(
{this.label = 'Input', this.key, {
this.belowWidgets = const [], this.label = 'Input',
this.defaultValue, this.belowWidgets = const [],
this.additionalValidators = const []}); this.defaultValue,
this.additionalValidators = const [],
});
} }
class GeneratedFormTextField extends GeneratedFormItem { class GeneratedFormTextField extends GeneratedFormItem {
@ -31,18 +33,19 @@ class GeneratedFormTextField extends GeneratedFormItem {
late TextInputType? textInputType; late TextInputType? textInputType;
late List<String>? autoCompleteOptions; late List<String>? autoCompleteOptions;
GeneratedFormTextField(super.key, GeneratedFormTextField(
{super.label, super.key, {
super.belowWidgets, super.label,
String super.defaultValue = '', super.belowWidgets,
List<String? Function(String? value)> super.additionalValidators = String super.defaultValue = '',
const [], List<String? Function(String? value)> super.additionalValidators = const [],
this.required = true, this.required = true,
this.max = 1, this.max = 1,
this.hint, this.hint,
this.password = false, this.password = false,
this.textInputType, this.textInputType,
this.autoCompleteOptions}); this.autoCompleteOptions,
});
@override @override
String ensureType(val) { String ensureType(val) {
@ -51,16 +54,18 @@ class GeneratedFormTextField extends GeneratedFormItem {
@override @override
GeneratedFormTextField clone() { GeneratedFormTextField clone() {
return GeneratedFormTextField(key, return GeneratedFormTextField(
label: label, key,
belowWidgets: belowWidgets, label: label,
defaultValue: defaultValue, belowWidgets: belowWidgets,
additionalValidators: List.from(additionalValidators), defaultValue: defaultValue,
required: required, additionalValidators: List.from(additionalValidators),
max: max, required: required,
hint: hint, max: max,
password: password, hint: hint,
textInputType: textInputType); password: password,
textInputType: textInputType,
);
} }
} }
@ -91,8 +96,9 @@ class GeneratedFormDropdown extends GeneratedFormItem {
label: label, label: label,
belowWidgets: belowWidgets, belowWidgets: belowWidgets,
defaultValue: defaultValue, defaultValue: defaultValue,
disabledOptKeys: disabledOptKeys: disabledOptKeys != null
disabledOptKeys != null ? List.from(disabledOptKeys!) : null, ? List.from(disabledOptKeys!)
: null,
additionalValidators: List.from(additionalValidators), additionalValidators: List.from(additionalValidators),
); );
} }
@ -117,12 +123,14 @@ class GeneratedFormSwitch extends GeneratedFormItem {
@override @override
GeneratedFormSwitch clone() { GeneratedFormSwitch clone() {
return GeneratedFormSwitch(key, return GeneratedFormSwitch(
label: label, key,
belowWidgets: belowWidgets, label: label,
defaultValue: defaultValue, belowWidgets: belowWidgets,
disabled: false, defaultValue: defaultValue,
additionalValidators: List.from(additionalValidators)); disabled: false,
additionalValidators: List.from(additionalValidators),
);
} }
} }
@ -132,17 +140,20 @@ class GeneratedFormTagInput extends GeneratedFormItem {
late WrapAlignment alignment; late WrapAlignment alignment;
late String emptyMessage; late String emptyMessage;
late bool showLabelWhenNotEmpty; late bool showLabelWhenNotEmpty;
GeneratedFormTagInput(super.key, GeneratedFormTagInput(
{super.label, super.key, {
super.belowWidgets, super.label,
Map<String, MapEntry<int, bool>> super.defaultValue = const {}, super.belowWidgets,
List<String? Function(Map<String, MapEntry<int, bool>> value)> Map<String, MapEntry<int, bool>> super.defaultValue = const {},
super.additionalValidators = const [], List<String? Function(Map<String, MapEntry<int, bool>> value)>
this.deleteConfirmationMessage, super.additionalValidators =
this.singleSelect = false, const [],
this.alignment = WrapAlignment.start, this.deleteConfirmationMessage,
this.emptyMessage = 'Input', this.singleSelect = false,
this.showLabelWhenNotEmpty = true}); this.alignment = WrapAlignment.start,
this.emptyMessage = 'Input',
this.showLabelWhenNotEmpty = true,
});
@override @override
Map<String, MapEntry<int, bool>> ensureType(val) { Map<String, MapEntry<int, bool>> ensureType(val) {
@ -151,25 +162,30 @@ class GeneratedFormTagInput extends GeneratedFormItem {
@override @override
GeneratedFormTagInput clone() { GeneratedFormTagInput clone() {
return GeneratedFormTagInput(key, return GeneratedFormTagInput(
label: label, key,
belowWidgets: belowWidgets, label: label,
defaultValue: defaultValue, belowWidgets: belowWidgets,
additionalValidators: List.from(additionalValidators), defaultValue: defaultValue,
deleteConfirmationMessage: deleteConfirmationMessage, additionalValidators: List.from(additionalValidators),
singleSelect: singleSelect, deleteConfirmationMessage: deleteConfirmationMessage,
alignment: alignment, singleSelect: singleSelect,
emptyMessage: emptyMessage, alignment: alignment,
showLabelWhenNotEmpty: showLabelWhenNotEmpty); emptyMessage: emptyMessage,
showLabelWhenNotEmpty: showLabelWhenNotEmpty,
);
} }
} }
typedef OnValueChanges = void Function( typedef OnValueChanges =
Map<String, dynamic> values, bool valid, bool isBuilding); void Function(Map<String, dynamic> values, bool valid, bool isBuilding);
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
const GeneratedForm( const GeneratedForm({
{super.key, required this.items, required this.onValueChanges}); super.key,
required this.items,
required this.onValueChanges,
});
final List<List<GeneratedFormItem>> items; final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges; final OnValueChanges onValueChanges;
@ -179,7 +195,8 @@ class GeneratedForm extends StatefulWidget {
} }
List<List<GeneratedFormItem>> cloneFormItems( List<List<GeneratedFormItem>> cloneFormItems(
List<List<GeneratedFormItem>> items) { List<List<GeneratedFormItem>> items,
) {
List<List<GeneratedFormItem>> clonedItems = []; List<List<GeneratedFormItem>> clonedItems = [];
for (var row in items) { for (var row in items) {
List<GeneratedFormItem> clonedRow = []; List<GeneratedFormItem> clonedRow = [];
@ -194,8 +211,13 @@ List<List<GeneratedFormItem>> cloneFormItems(
class GeneratedFormSubForm extends GeneratedFormItem { class GeneratedFormSubForm extends GeneratedFormItem {
final List<List<GeneratedFormItem>> items; final List<List<GeneratedFormItem>> items;
GeneratedFormSubForm(super.key, this.items, GeneratedFormSubForm(
{super.label, super.belowWidgets, super.defaultValue = const []}); super.key,
this.items, {
super.label,
super.belowWidgets,
super.defaultValue = const [],
});
@override @override
ensureType(val) { ensureType(val) {
@ -204,8 +226,13 @@ class GeneratedFormSubForm extends GeneratedFormItem {
@override @override
GeneratedFormSubForm clone() { GeneratedFormSubForm clone() {
return GeneratedFormSubForm(key, cloneFormItems(items), return GeneratedFormSubForm(
label: label, belowWidgets: belowWidgets, defaultValue: defaultValue); key,
cloneFormItems(items),
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
);
} }
} }
@ -220,13 +247,18 @@ Color generateRandomLightColor() {
// Map from HPLuv color space to RGB, use constant saturation=100, lightness=70 // Map from HPLuv color space to RGB, use constant saturation=100, lightness=70
final List<double> rgbValuesDbl = Hsluv.hpluvToRgb([hue, 100, 70]); final List<double> rgbValuesDbl = Hsluv.hpluvToRgb([hue, 100, 70]);
// Map RBG values from 0-1 to 0-255: // Map RBG values from 0-1 to 0-255:
final List<int> rgbValues = final List<int> rgbValues = rgbValuesDbl
rgbValuesDbl.map((rgb) => (rgb * 255).toInt()).toList(); .map((rgb) => (rgb * 255).toInt())
.toList();
return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]); return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]);
} }
int generateRandomNumber(int seed1, int generateRandomNumber(
{int seed2 = 0, int seed3 = 0, max = 10000}) { int seed1, {
int seed2 = 0,
int seed3 = 0,
max = 10000,
}) {
int combinedSeed = seed1.hashCode ^ seed2.hashCode ^ seed3.hashCode; int combinedSeed = seed1.hashCode ^ seed2.hashCode ^ seed3.hashCode;
Random random = Random(combinedSeed); Random random = Random(combinedSeed);
int randomNumber = random.nextInt(max); int randomNumber = random.nextInt(max);
@ -261,7 +293,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
widget.onValueChanges(returnValues, valid, isBuilding); widget.onValueChanges(returnValues, valid, isBuilding);
} }
initForm() { void initForm() {
initKey = widget.key.toString(); initKey = widget.key.toString();
// Initialize form values as all empty // Initialize form values as all empty
values.clear(); values.clear();
@ -297,9 +329,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
helperText: helperText: formItem.label + (formItem.required ? ' *' : ''),
formItem.label + (formItem.required ? ' *' : ''), hintText: formItem.hint,
hintText: formItem.hint), ),
minLines: formItem.max <= 1 ? null : formItem.max, minLines: formItem.max <= 1 ? null : formItem.max,
maxLines: formItem.max <= 1 ? 1 : formItem.max, maxLines: formItem.max <= 1 ? 1 : formItem.max,
validator: (value) { validator: (value) {
@ -339,23 +371,26 @@ class _GeneratedFormState extends State<GeneratedForm> {
return Text(tr('dropdownNoOptsError')); return Text(tr('dropdownNoOptsError'));
} }
return DropdownButtonFormField( return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label), decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key], value: values[formItem.key],
items: formItem.opts!.map((e2) { items: formItem.opts!.map((e2) {
var enabled = var enabled = formItem.disabledOptKeys?.contains(e2.key) != true;
formItem.disabledOptKeys?.contains(e2.key) != true; return DropdownMenuItem(
return DropdownMenuItem( value: e2.key,
value: e2.key, enabled: enabled,
enabled: enabled, child: Opacity(
child: Opacity( opacity: enabled ? 1 : 0.5,
opacity: enabled ? 1 : 0.5, child: Text(e2.value))); child: Text(e2.value),
}).toList(), ),
onChanged: (value) { );
setState(() { }).toList(),
values[formItem.key] = value ?? formItem.opts!.first.key; onChanged: (value) {
someValueChanged(); setState(() {
}); values[formItem.key] = value ?? formItem.opts!.first.key;
someValueChanged();
}); });
},
);
} else if (formItem is GeneratedFormSubForm) { } else if (formItem is GeneratedFormSubForm) {
values[formItem.key] = []; values[formItem.key] = [];
for (Map<String, dynamic> v for (Map<String, dynamic> v
@ -394,33 +429,33 @@ class _GeneratedFormState extends State<GeneratedForm> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Flexible(child: Text(widget.items[r][e].label)), Flexible(child: Text(widget.items[r][e].label)),
const SizedBox( const SizedBox(width: 8),
width: 8,
),
Switch( Switch(
value: values[fieldKey], value: values[fieldKey],
onChanged: onChanged: (widget.items[r][e] as GeneratedFormSwitch).disabled
(widget.items[r][e] as GeneratedFormSwitch).disabled ? null
? null : (value) {
: (value) { setState(() {
setState(() { values[fieldKey] = value;
values[fieldKey] = value; someValueChanged();
someValueChanged(); });
}); },
}) ),
], ],
); );
} else if (widget.items[r][e] is GeneratedFormTagInput) { } else if (widget.items[r][e] is GeneratedFormTagInput) {
onAddPressed() { onAddPressed() {
showDialog<Map<String, dynamic>?>( showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: widget.items[r][e].label, title: widget.items[r][e].label,
items: [ items: [
[GeneratedFormTextField('label', label: tr('label'))] [GeneratedFormTextField('label', label: tr('label'))],
]); ],
}).then((value) { );
},
).then((value) {
String? label = value?['label']; String? label = value?['label'];
if (label != null) { if (label != null) {
setState(() { setState(() {
@ -434,8 +469,10 @@ class _GeneratedFormState extends State<GeneratedForm> {
var someSelected = temp.entries var someSelected = temp.entries
.where((element) => element.value.value) .where((element) => element.value.value)
.isNotEmpty; .isNotEmpty;
temp[label] = MapEntry(generateRandomLightColor().value, temp[label] = MapEntry(
!(someSelected && singleSelect)); generateRandomLightColor().value,
!(someSelected && singleSelect),
);
values[fieldKey] = temp; values[fieldKey] = temp;
someValueChanged(); someValueChanged();
} }
@ -444,236 +481,274 @@ class _GeneratedFormState extends State<GeneratedForm> {
}); });
} }
formInputs[r][e] = formInputs[r][e] = Column(
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
if ((values[fieldKey] as Map<String, MapEntry<int, bool>>?) children: [
?.isNotEmpty == if ((values[fieldKey] as Map<String, MapEntry<int, bool>>?)
true && ?.isNotEmpty ==
(widget.items[r][e] as GeneratedFormTagInput) true &&
.showLabelWhenNotEmpty) (widget.items[r][e] as GeneratedFormTagInput)
Column( .showLabelWhenNotEmpty)
crossAxisAlignment: Column(
(widget.items[r][e] as GeneratedFormTagInput).alignment == crossAxisAlignment:
WrapAlignment.center (widget.items[r][e] as GeneratedFormTagInput).alignment ==
? CrossAxisAlignment.center WrapAlignment.center
: CrossAxisAlignment.stretch, ? CrossAxisAlignment.center
: CrossAxisAlignment.stretch,
children: [
Text(widget.items[r][e].label),
const SizedBox(height: 8),
],
),
Wrap(
alignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Text(widget.items[r][e].label), // (values[fieldKey] as Map<String, MapEntry<int, bool>>?)
const SizedBox( // ?.isEmpty ==
height: 8, // true
), // ? Text(
], // (widget.items[r][e] as GeneratedFormTagInput)
), // .emptyMessage,
Wrap( // )
alignment: // : const SizedBox.shrink(),
(widget.items[r][e] as GeneratedFormTagInput).alignment, ...(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
crossAxisAlignment: WrapCrossAlignment.center, ?.entries
children: [ .map((e2) {
// (values[fieldKey] as Map<String, MapEntry<int, bool>>?) return Padding(
// ?.isEmpty == padding: const EdgeInsets.symmetric(
// true horizontal: 4,
// ? Text( ),
// (widget.items[r][e] as GeneratedFormTagInput) child: ChoiceChip(
// .emptyMessage, label: Text(e2.key),
// ) backgroundColor: Color(
// : const SizedBox.shrink(), e2.value.key,
...(values[fieldKey] as Map<String, MapEntry<int, bool>>?) ).withAlpha(50),
?.entries selectedColor: Color(e2.value.key),
.map((e2) { visualDensity: VisualDensity.compact,
return Padding( selected: e2.value.value,
padding: const EdgeInsets.symmetric(horizontal: 4), onSelected: (value) {
child: ChoiceChip( setState(() {
label: Text(e2.key), (values[fieldKey]
backgroundColor: Color(e2.value.key).withAlpha(50), as Map<String, MapEntry<int, bool>>)[e2
selectedColor: Color(e2.value.key), .key] = MapEntry(
visualDensity: VisualDensity.compact, (values[fieldKey]
selected: e2.value.value, as Map<
onSelected: (value) { String,
setState(() { MapEntry<int, bool>
(values[fieldKey] as Map<String, >)[e2.key]!
MapEntry<int, bool>>)[e2.key] = .key,
MapEntry( value,
(values[fieldKey] as Map<String, );
MapEntry<int, bool>>)[e2.key]! if ((widget.items[r][e]
.key, as GeneratedFormTagInput)
value); .singleSelect &&
if ((widget.items[r][e] value == true) {
as GeneratedFormTagInput) for (var key
.singleSelect && in (values[fieldKey]
value == true) { as Map<
for (var key in (values[fieldKey] String,
as Map<String, MapEntry<int, bool>>) MapEntry<int, bool>
.keys) { >)
if (key != e2.key) { .keys) {
(values[fieldKey] as Map< if (key != e2.key) {
String, (values[fieldKey]
MapEntry<int, as Map<
bool>>)[key] = MapEntry( String,
(values[fieldKey] as Map<String, MapEntry<int, bool>
MapEntry<int, bool>>)[key]! >)[key] = MapEntry(
.key, (values[fieldKey]
false); as Map<
String,
MapEntry<int, bool>
>)[key]!
.key,
false,
);
}
}
} }
} someValueChanged();
} });
someValueChanged(); },
}); ),
}, );
)); }) ??
}) ?? [const SizedBox.shrink()],
[const SizedBox.shrink()], (values[fieldKey] as Map<String, MapEntry<int, bool>>?)
(values[fieldKey] as Map<String, MapEntry<int, bool>>?) ?.values
?.values .where((e) => e.value)
.where((e) => e.value) .length ==
.length == 1
1 ? Padding(
? Padding( padding: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 4), child: IconButton(
child: IconButton( onPressed: () {
onPressed: () {
setState(() {
var temp = values[fieldKey]
as Map<String, MapEntry<int, bool>>;
// get selected category str where bool is true
final oldEntry = temp.entries
.firstWhere((entry) => entry.value.value);
// generate new color, ensure it is not the same
int newColor = oldEntry.value.key;
while (oldEntry.value.key == newColor) {
newColor = generateRandomLightColor().value;
}
// Update entry with new color, remain selected
temp.update(oldEntry.key,
(old) => MapEntry(newColor, old.value));
values[fieldKey] = temp;
someValueChanged();
});
},
icon: const Icon(Icons.format_color_fill_rounded),
visualDensity: VisualDensity.compact,
tooltip: tr('colour'),
))
: const SizedBox.shrink(),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.isNotEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
fn() {
setState(() { setState(() {
var temp = values[fieldKey] var temp =
as Map<String, MapEntry<int, bool>>; values[fieldKey]
temp.removeWhere((key, value) => value.value); as Map<String, MapEntry<int, bool>>;
// get selected category str where bool is true
final oldEntry = temp.entries.firstWhere(
(entry) => entry.value.value,
);
// generate new color, ensure it is not the same
int newColor = oldEntry.value.key;
while (oldEntry.value.key == newColor) {
newColor = generateRandomLightColor().value;
}
// Update entry with new color, remain selected
temp.update(
oldEntry.key,
(old) => MapEntry(newColor, old.value),
);
values[fieldKey] = temp; values[fieldKey] = temp;
someValueChanged(); someValueChanged();
}); });
} },
icon: const Icon(Icons.format_color_fill_rounded),
visualDensity: VisualDensity.compact,
tooltip: tr('colour'),
),
)
: const SizedBox.shrink(),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.isNotEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
fn() {
setState(() {
var temp =
values[fieldKey]
as Map<String, MapEntry<int, bool>>;
temp.removeWhere((key, value) => value.value);
values[fieldKey] = temp;
someValueChanged();
});
}
if ((widget.items[r][e] as GeneratedFormTagInput) if ((widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage != .deleteConfirmationMessage !=
null) { null) {
var message = var message =
(widget.items[r][e] as GeneratedFormTagInput) (widget.items[r][e]
.deleteConfirmationMessage!; as GeneratedFormTagInput)
showDialog<Map<String, dynamic>?>( .deleteConfirmationMessage!;
showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: message.key, title: message.key,
message: message.value, message: message.value,
items: const []); items: const [],
}).then((value) { );
if (value != null) { },
fn(); ).then((value) {
} if (value != null) {
}); fn();
} else { }
fn(); });
} } else {
}, fn();
icon: const Icon(Icons.remove), }
visualDensity: VisualDensity.compact, },
tooltip: tr('remove'), icon: const Icon(Icons.remove),
)) visualDensity: VisualDensity.compact,
: const SizedBox.shrink(), tooltip: tr('remove'),
(values[fieldKey] as Map<String, MapEntry<int, bool>>?) ),
?.isEmpty == )
true : const SizedBox.shrink(),
? Padding( (values[fieldKey] as Map<String, MapEntry<int, bool>>?)
padding: const EdgeInsets.symmetric(horizontal: 4), ?.isEmpty ==
child: TextButton.icon( true
onPressed: onAddPressed, ? Padding(
icon: const Icon(Icons.add), padding: const EdgeInsets.symmetric(horizontal: 4),
label: Text( child: TextButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(
(widget.items[r][e] as GeneratedFormTagInput) (widget.items[r][e] as GeneratedFormTagInput)
.label), .label,
)) ),
: Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 4), )
child: IconButton( : Padding(
onPressed: onAddPressed, padding: const EdgeInsets.symmetric(horizontal: 4),
icon: const Icon(Icons.add), child: IconButton(
visualDensity: VisualDensity.compact, onPressed: onAddPressed,
tooltip: tr('add'), icon: const Icon(Icons.add),
)), visualDensity: VisualDensity.compact,
], tooltip: tr('add'),
) ),
]); ),
],
),
],
);
} else if (widget.items[r][e] is GeneratedFormSubForm) { } else if (widget.items[r][e] is GeneratedFormSubForm) {
List<Widget> subformColumn = []; List<Widget> subformColumn = [];
var compact = (widget.items[r][e] as GeneratedFormSubForm) var compact =
.items (widget.items[r][e] as GeneratedFormSubForm).items.length == 1 &&
.length ==
1 &&
(widget.items[r][e] as GeneratedFormSubForm).items[0].length == 1; (widget.items[r][e] as GeneratedFormSubForm).items[0].length == 1;
for (int i = 0; i < values[fieldKey].length; i++) { for (int i = 0; i < values[fieldKey].length; i++) {
var internalFormKey = ValueKey(generateRandomNumber( var internalFormKey = ValueKey(
generateRandomNumber(
values[fieldKey].length, values[fieldKey].length,
seed2: i, seed2: i,
seed3: forceUpdateKeyCount)); seed3: forceUpdateKeyCount,
subformColumn.add(Column( ),
crossAxisAlignment: CrossAxisAlignment.start, );
children: [ subformColumn.add(
if (!compact) Column(
const SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
height: 16, children: [
if (!compact) const SizedBox(height: 16),
if (!compact)
Text(
'${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
GeneratedForm(
key: internalFormKey,
items:
cloneFormItems(
(widget.items[r][e] as GeneratedFormSubForm)
.items,
)
.map(
(x) => x.map((y) {
y.defaultValue = values[fieldKey]?[i]?[y.key];
y.key = '${y.key.toString()},$internalFormKey';
return y;
}).toList(),
)
.toList(),
onValueChanges: (values, valid, isBuilding) {
values = values.map(
(key, value) => MapEntry(key.split(',')[0], value),
);
if (valid) {
this.values[fieldKey]?[i] = values;
}
someValueChanged(
isBuilding: isBuilding,
forceInvalid: !valid,
);
},
), ),
if (!compact) Row(
Text( mainAxisAlignment: MainAxisAlignment.end,
'${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', children: [
style: const TextStyle(fontWeight: FontWeight.bold), TextButton.icon(
),
GeneratedForm(
key: internalFormKey,
items: cloneFormItems(
(widget.items[r][e] as GeneratedFormSubForm).items)
.map((x) => x.map((y) {
y.defaultValue = values[fieldKey]?[i]?[y.key];
y.key = '${y.key.toString()},$internalFormKey';
return y;
}).toList())
.toList(),
onValueChanges: (values, valid, isBuilding) {
values = values.map(
(key, value) => MapEntry(key.split(',')[0], value));
if (valid) {
this.values[fieldKey]?[i] = values;
}
someValueChanged(
isBuilding: isBuilding, forceInvalid: !valid);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: foregroundColor: Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.error), ),
onPressed: (values[fieldKey].length > 0) onPressed: (values[fieldKey].length > 0)
? () { ? () {
var temp = List.from(values[fieldKey]); var temp = List.from(values[fieldKey]);
@ -686,33 +761,40 @@ class _GeneratedFormState extends State<GeneratedForm> {
label: Text( label: Text(
'${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})',
), ),
icon: const Icon( icon: const Icon(Icons.delete_outline_rounded),
Icons.delete_outline_rounded, ),
)) ],
], ),
) ],
], ),
)); );
} }
subformColumn.add(Padding( subformColumn.add(
padding: const EdgeInsets.only(bottom: 0, top: 8), Padding(
child: Row( padding: const EdgeInsets.only(bottom: 0, top: 8),
children: [ child: Row(
Expanded( children: [
Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () { onPressed: () {
values[fieldKey].add(getDefaultValuesFromFormItems( values[fieldKey].add(
(widget.items[r][e] as GeneratedFormSubForm) getDefaultValuesFromFormItems(
.items)); (widget.items[r][e] as GeneratedFormSubForm).items,
forceUpdateKeyCount++; ),
someValueChanged(); );
}, forceUpdateKeyCount++;
icon: const Icon(Icons.add), someValueChanged();
label: Text((widget.items[r][e] as GeneratedFormSubForm) },
.label))), icon: const Icon(Icons.add),
], label: Text(
(widget.items[r][e] as GeneratedFormSubForm).label,
),
),
),
],
),
), ),
)); );
formInputs[r][e] = Column(children: subformColumn); formInputs[r][e] = Column(children: subformColumn);
} }
} }
@ -726,38 +808,43 @@ class _GeneratedFormState extends State<GeneratedForm> {
height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
? 8 ? 8
: 25, : 25,
) ),
]); ]);
} }
List<Widget> rowItems = []; List<Widget> rowItems = [];
rowInputs.value.asMap().entries.forEach((rowInput) { rowInputs.value.asMap().entries.forEach((rowInput) {
if (rowInput.key > 0) { if (rowInput.key > 0) {
rowItems.add(const SizedBox( rowItems.add(const SizedBox(width: 20));
width: 20,
));
} }
rowItems.add(Expanded( rowItems.add(
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
rowInput.value, rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets ...widget.items[rowInputs.key][rowInput.key].belowWidgets,
]))); ],
),
),
);
}); });
rows.add(rowItems); rows.add(rowItems);
}); });
return Form( return Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
children: [ children: [
...rows.map((row) => Row( ...rows.map(
mainAxisAlignment: MainAxisAlignment.start, (row) => Row(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [...row.map((e) => e)], crossAxisAlignment: CrossAxisAlignment.start,
)) children: [...row.map((e) => e)],
], ),
)); ),
],
),
);
} }
} }

View File

@ -4,15 +4,16 @@ import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
class GeneratedFormModal extends StatefulWidget { class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal( const GeneratedFormModal({
{super.key, super.key,
required this.title, required this.title,
required this.items, required this.items,
this.initValid = false, this.initValid = false,
this.message = '', this.message = '',
this.additionalWidgets = const [], this.additionalWidgets = const [],
this.singleNullReturnButton, this.singleNullReturnButton,
this.primaryActionColour}); this.primaryActionColour,
});
final String title; final String title;
final String message; final String message;
@ -41,14 +42,12 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: Text(widget.title), title: Text(widget.title),
content: content: Column(
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
if (widget.message.isNotEmpty) Text(widget.message), children: [
if (widget.message.isNotEmpty) if (widget.message.isNotEmpty) Text(widget.message),
const SizedBox( if (widget.message.isNotEmpty) const SizedBox(height: 16),
height: 16, GeneratedForm(
),
GeneratedForm(
items: widget.items, items: widget.items,
onValueChanges: (values, valid, isBuilding) { onValueChanges: (values, valid, isBuilding) {
if (isBuilding) { if (isBuilding) {
@ -60,23 +59,29 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
this.valid = valid; this.valid = valid;
}); });
} }
}), },
if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets ),
]), if (widget.additionalWidgets.isNotEmpty) ...widget.additionalWidgets,
],
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: Text(widget.singleNullReturnButton == null child: Text(
widget.singleNullReturnButton == null
? tr('cancel') ? tr('cancel')
: widget.singleNullReturnButton!)), : widget.singleNullReturnButton!,
),
),
widget.singleNullReturnButton == null widget.singleNullReturnButton == null
? TextButton( ? TextButton(
style: widget.primaryActionColour == null style: widget.primaryActionColour == null
? null ? null
: TextButton.styleFrom( : TextButton.styleFrom(
foregroundColor: widget.primaryActionColour), foregroundColor: widget.primaryActionColour,
),
onPressed: !valid onPressed: !valid
? null ? null
: () { : () {
@ -85,8 +90,9 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
Navigator.of(context).pop(values); Navigator.of(context).pop(values);
} }
}, },
child: Text(tr('continue'))) child: Text(tr('continue')),
: const SizedBox.shrink() )
: const SizedBox.shrink(),
], ],
); );
} }

View File

@ -20,23 +20,24 @@ class ObtainiumError {
class RateLimitError extends ObtainiumError { class RateLimitError extends ObtainiumError {
late int remainingMinutes; late int remainingMinutes;
RateLimitError(this.remainingMinutes) RateLimitError(this.remainingMinutes)
: super(plural('tooManyRequestsTryAgainInMinutes', remainingMinutes)); : super(plural('tooManyRequestsTryAgainInMinutes', remainingMinutes));
} }
class InvalidURLError extends ObtainiumError { class InvalidURLError extends ObtainiumError {
InvalidURLError(String sourceName) InvalidURLError(String sourceName)
: super(tr('invalidURLForSource', args: [sourceName])); : super(tr('invalidURLForSource', args: [sourceName]));
} }
class CredsNeededError extends ObtainiumError { class CredsNeededError extends ObtainiumError {
CredsNeededError(String sourceName) CredsNeededError(String sourceName)
: super(tr('requiresCredentialsInSettings', args: [sourceName])); : super(tr('requiresCredentialsInSettings', args: [sourceName]));
} }
class NoReleasesError extends ObtainiumError { class NoReleasesError extends ObtainiumError {
NoReleasesError({String? note}) NoReleasesError({String? note})
: super( : super(
'${tr('noReleaseFound')}${note?.isNotEmpty == true ? '\n\n$note' : ''}'); '${tr('noReleaseFound')}${note?.isNotEmpty == true ? '\n\n$note' : ''}',
);
} }
class NoAPKError extends ObtainiumError { class NoAPKError extends ObtainiumError {
@ -57,7 +58,7 @@ class DowngradeError extends ObtainiumError {
class InstallError extends ObtainiumError { class InstallError extends ObtainiumError {
InstallError(int code) InstallError(int code)
: super(PackageInstallerStatus.byCode(code).name.substring(7)); : super(PackageInstallerStatus.byCode(code).name.substring(7));
} }
class IDChangedError extends ObtainiumError { class IDChangedError extends ObtainiumError {
@ -75,7 +76,7 @@ class MultiAppMultiError extends ObtainiumError {
MultiAppMultiError() : super(tr('placeholder'), unexpected: true); MultiAppMultiError() : super(tr('placeholder'), unexpected: true);
add(String appId, dynamic error, {String? appName}) { void add(String appId, dynamic error, {String? appName}) {
if (error is SocketException) { if (error is SocketException) {
error = error.message; error = error.message;
} }
@ -93,8 +94,11 @@ class MultiAppMultiError extends ObtainiumError {
String errorString(String appId, {bool includeIdsWithNames = false}) => String errorString(String appId, {bool includeIdsWithNames = false}) =>
'${appIdNames.containsKey(appId) ? '${appIdNames[appId]}${includeIdsWithNames ? ' ($appId)' : ''}' : appId}: ${rawErrors[appId].toString()}'; '${appIdNames.containsKey(appId) ? '${appIdNames[appId]}${includeIdsWithNames ? ' ($appId)' : ''}' : appId}: ${rawErrors[appId].toString()}';
String errorsAppsString(String errString, List<String> appIds, String errorsAppsString(
{bool includeIdsWithNames = false}) => String errString,
List<String> appIds, {
bool includeIdsWithNames = false,
}) =>
'$errString [${list2FriendlyString(appIds.map((id) => appIdNames.containsKey(id) == true ? '${appIdNames[id]}${includeIdsWithNames ? ' ($id)' : ''}' : id).toList())}]'; '$errString [${list2FriendlyString(appIds.map((id) => appIdNames.containsKey(id) == true ? '${appIdNames[id]}${includeIdsWithNames ? ' ($id)' : ''}' : id).toList())}]';
@override @override
@ -103,43 +107,50 @@ class MultiAppMultiError extends ObtainiumError {
.join('\n\n'); .join('\n\n');
} }
showMessage(dynamic e, BuildContext context, {bool isError = false}) { void showMessage(dynamic e, BuildContext context, {bool isError = false}) {
Provider.of<LogsProvider>(context, listen: false) Provider.of<LogsProvider>(
.add(e.toString(), level: isError ? LogLevels.error : LogLevels.info); context,
listen: false,
).add(e.toString(), level: isError ? LogLevels.error : LogLevels.info);
if (e is String || (e is ObtainiumError && !e.unexpected)) { if (e is String || (e is ObtainiumError && !e.unexpected)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(e.toString())), context,
); ).showSnackBar(SnackBar(content: Text(e.toString())));
} else { } else {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: Text(e is MultiAppMultiError title: Text(
e is MultiAppMultiError
? tr(isError ? 'someErrors' : 'updates') ? tr(isError ? 'someErrors' : 'updates')
: tr(isError ? 'unexpectedError' : 'unknown')), : tr(isError ? 'unexpectedError' : 'unknown'),
content: GestureDetector( ),
onLongPress: () { content: GestureDetector(
Clipboard.setData(ClipboardData(text: e.toString())); onLongPress: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( Clipboard.setData(ClipboardData(text: e.toString()));
content: Text(tr('copiedToClipboard')), ScaffoldMessenger.of(
)); context,
}, ).showSnackBar(SnackBar(content: Text(tr('copiedToClipboard'))));
child: Text(e.toString())), },
actions: [ child: Text(e.toString()),
TextButton( ),
onPressed: () { actions: [
Navigator.of(context).pop(null); TextButton(
}, onPressed: () {
child: Text(tr('ok'))), Navigator.of(context).pop(null);
], },
); child: Text(tr('ok')),
}); ),
],
);
},
);
} }
} }
showError(dynamic e, BuildContext context) { void showError(dynamic e, BuildContext context) {
showMessage(e, context, isError: true); showMessage(e, context, isError: true);
} }
@ -147,14 +158,16 @@ String list2FriendlyString(List<String> list) {
return list.length == 2 return list.length == 2
? '${list[0]} ${tr('and')} ${list[1]}' ? '${list[0]} ${tr('and')} ${list[1]}'
: list : list
.asMap() .asMap()
.entries .entries
.map((e) => .map(
e.value + (e) =>
(e.key == list.length - 1 e.value +
? '' (e.key == list.length - 1
: e.key == list.length - 2 ? ''
: e.key == list.length - 2
? ' and ' ? ' and '
: ', ')) : ', '),
.join(''); )
.join('');
} }

View File

@ -43,8 +43,10 @@ List<MapEntry<Locale, String>> supportedLocales = const [
MapEntry(Locale('tr'), 'Türkçe'), MapEntry(Locale('tr'), 'Türkçe'),
MapEntry(Locale('uk'), 'Українська'), MapEntry(Locale('uk'), 'Українська'),
MapEntry(Locale('da'), 'Dansk'), MapEntry(Locale('da'), 'Dansk'),
MapEntry(Locale('en', 'EO'), MapEntry(
'Esperanto'), // https://github.com/aissat/easy_localization/issues/220#issuecomment-846035493 Locale('en', 'EO'),
'Esperanto',
), // https://github.com/aissat/easy_localization/issues/220#issuecomment-846035493
MapEntry(Locale('in'), 'Bahasa Indonesia'), MapEntry(Locale('in'), 'Bahasa Indonesia'),
MapEntry(Locale('ko'), '한국어'), MapEntry(Locale('ko'), '한국어'),
MapEntry(Locale('ca'), 'Català'), MapEntry(Locale('ca'), 'Català'),
@ -76,9 +78,11 @@ Future<void> loadTranslations() async {
}, },
); );
await controller.loadTranslations(); await controller.loadTranslations();
Localization.load(controller.locale, Localization.load(
translations: controller.translations, controller.locale,
fallbackTranslations: controller.fallbackTranslations); translations: controller.translations,
fallbackTranslations: controller.fallbackTranslations,
);
} }
@pragma('vm:entry-point') @pragma('vm:entry-point')
@ -97,10 +101,12 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async {
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
try { try {
ByteData data = ByteData data = await PlatformAssetBundle().load(
await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem'); 'assets/ca/lets-encrypt-r3.pem',
SecurityContext.defaultContext );
.setTrustedCertificatesBytes(data.buffer.asUint8List()); SecurityContext.defaultContext.setTrustedCertificatesBytes(
data.buffer.asUint8List(),
);
} catch (e) { } catch (e) {
// Already added, do nothing (see #375) // Already added, do nothing (see #375)
} }
@ -113,20 +119,23 @@ void main() async {
} }
final np = NotificationsProvider(); final np = NotificationsProvider();
await np.initialize(); await np.initialize();
runApp(MultiProvider( runApp(
providers: [ MultiProvider(
ChangeNotifierProvider(create: (context) => AppsProvider()), providers: [
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => AppsProvider()),
Provider(create: (context) => np), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => LogsProvider()) Provider(create: (context) => np),
], Provider(create: (context) => LogsProvider()),
child: EasyLocalization( ],
child: EasyLocalization(
supportedLocales: supportedLocales.map((e) => e.key).toList(), supportedLocales: supportedLocales.map((e) => e.key).toList(),
path: localeDir, path: localeDir,
fallbackLocale: fallbackLocale, fallbackLocale: fallbackLocale,
useOnlyLangCode: false, useOnlyLangCode: false,
child: const Obtainium()), child: const Obtainium(),
)); ),
),
);
BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
} }
@ -148,22 +157,26 @@ class _ObtainiumState extends State<Obtainium> {
Future<void> initPlatformState() async { Future<void> initPlatformState() async {
await BackgroundFetch.configure( await BackgroundFetch.configure(
BackgroundFetchConfig( BackgroundFetchConfig(
minimumFetchInterval: 15, minimumFetchInterval: 15,
stopOnTerminate: false, stopOnTerminate: false,
startOnBoot: true, startOnBoot: true,
enableHeadless: true, enableHeadless: true,
requiresBatteryNotLow: false, requiresBatteryNotLow: false,
requiresCharging: false, requiresCharging: false,
requiresStorageNotLow: false, requiresStorageNotLow: false,
requiresDeviceIdle: false, requiresDeviceIdle: false,
requiredNetworkType: NetworkType.ANY), (String taskId) async { requiredNetworkType: NetworkType.ANY,
await bgUpdateCheck(taskId, null); ),
BackgroundFetch.finish(taskId); (String taskId) async {
}, (String taskId) async { await bgUpdateCheck(taskId, null);
context.read<LogsProvider>().add('BG update task timed out.'); BackgroundFetch.finish(taskId);
BackgroundFetch.finish(taskId); },
}); (String taskId) async {
context.read<LogsProvider>().add('BG update task timed out.');
BackgroundFetch.finish(taskId);
},
);
if (!mounted) return; if (!mounted) return;
} }
@ -183,30 +196,33 @@ class _ObtainiumState extends State<Obtainium> {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request(); Permission.notification.request();
if (!fdroid) { if (!fdroid) {
getInstalledInfo(obtainiumId).then((value) { getInstalledInfo(obtainiumId)
if (value?.versionName != null) { .then((value) {
appsProvider.saveApps([ if (value?.versionName != null) {
App( appsProvider.saveApps([
obtainiumId, App(
obtainiumUrl, obtainiumId,
'ImranR98', obtainiumUrl,
'Obtainium', 'ImranR98',
value!.versionName, 'Obtainium',
value.versionName!, value!.versionName,
[], value.versionName!,
0, [],
{ 0,
'versionDetection': true, {
'apkFilterRegEx': 'fdroid', 'versionDetection': true,
'invertAPKFilter': true 'apkFilterRegEx': 'fdroid',
}, 'invertAPKFilter': true,
null, },
false) null,
], onlyIfExists: false); false,
} ),
}).catchError((err) { ], onlyIfExists: false);
print(err); }
}); })
.catchError((err) {
print(err);
});
} }
} }
if (!supportedLocales.map((e) => e.key).contains(context.locale) || if (!supportedLocales.map((e) => e.key).contains(context.locale) ||
@ -221,32 +237,35 @@ class _ObtainiumState extends State<Obtainium> {
}); });
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Decide on a colour/brightness scheme based on OS and user settings // Decide on a colour/brightness scheme based on OS and user settings
ColorScheme lightColorScheme; ColorScheme lightColorScheme;
ColorScheme darkColorScheme; ColorScheme darkColorScheme;
if (lightDynamic != null && if (lightDynamic != null &&
darkDynamic != null && darkDynamic != null &&
settingsProvider.useMaterialYou) { settingsProvider.useMaterialYou) {
lightColorScheme = lightDynamic.harmonized(); lightColorScheme = lightDynamic.harmonized();
darkColorScheme = darkDynamic.harmonized(); darkColorScheme = darkDynamic.harmonized();
} else { } else {
lightColorScheme = lightColorScheme = ColorScheme.fromSeed(
ColorScheme.fromSeed(seedColor: settingsProvider.themeColor);
darkColorScheme = ColorScheme.fromSeed(
seedColor: settingsProvider.themeColor, seedColor: settingsProvider.themeColor,
brightness: Brightness.dark); );
} darkColorScheme = ColorScheme.fromSeed(
seedColor: settingsProvider.themeColor,
brightness: Brightness.dark,
);
}
// set the background and surface colors to pure black in the amoled theme // set the background and surface colors to pure black in the amoled theme
if (settingsProvider.useBlackTheme) { if (settingsProvider.useBlackTheme) {
darkColorScheme = darkColorScheme = darkColorScheme
darkColorScheme.copyWith(surface: Colors.black).harmonized(); .copyWith(surface: Colors.black)
} .harmonized();
}
if (settingsProvider.useSystemFont) NativeFeatures.loadSystemFont(); if (settingsProvider.useSystemFont) NativeFeatures.loadSystemFont();
return MaterialApp( return MaterialApp(
title: 'Obtainium', title: 'Obtainium',
localizationsDelegates: context.localizationDelegates, localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
@ -254,22 +273,31 @@ class _ObtainiumState extends State<Obtainium> {
navigatorKey: globalNavigatorKey, navigatorKey: globalNavigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.dark colorScheme: settingsProvider.theme == ThemeSettings.dark
? darkColorScheme ? darkColorScheme
: lightColorScheme, : lightColorScheme,
fontFamily: fontFamily: settingsProvider.useSystemFont
settingsProvider.useSystemFont ? 'SystemFont' : 'Montserrat'), ? 'SystemFont'
: 'Montserrat',
),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.light colorScheme: settingsProvider.theme == ThemeSettings.light
? lightColorScheme ? lightColorScheme
: darkColorScheme, : darkColorScheme,
fontFamily: fontFamily: settingsProvider.useSystemFont
settingsProvider.useSystemFont ? 'SystemFont' : 'Montserrat'), ? 'SystemFont'
home: Shortcuts(shortcuts: <LogicalKeySet, Intent>{ : 'Montserrat',
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), ),
}, child: const HomePage())); home: Shortcuts(
}); shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
},
child: const HomePage(),
),
);
},
);
} }
} }

View File

@ -14,11 +14,15 @@ class GitHubStars implements MassAppUrlSource {
late List<String> requiredArgs = [tr('uname')]; late List<String> requiredArgs = [tr('uname')];
Future<Map<String, List<String>>> getOnePageOfUserStarredUrlsWithDescriptions( Future<Map<String, List<String>>> getOnePageOfUserStarredUrlsWithDescriptions(
String username, int page) async { String username,
int page,
) async {
Response res = await get( Response res = await get(
Uri.parse( Uri.parse(
'https://api.github.com/users/$username/starred?per_page=100&page=$page'), 'https://api.github.com/users/$username/starred?per_page=100&page=$page',
headers: await GitHub().getRequestHeaders({})); ),
headers: await GitHub().getRequestHeaders({}),
);
if (res.statusCode == 200) { if (res.statusCode == 200) {
Map<String, List<String>> urlsWithDescriptions = {}; Map<String, List<String>> urlsWithDescriptions = {};
for (var e in (jsonDecode(res.body) as List<dynamic>)) { for (var e in (jsonDecode(res.body) as List<dynamic>)) {
@ -27,8 +31,8 @@ class GitHubStars implements MassAppUrlSource {
e['full_name'] as String, e['full_name'] as String,
e['description'] != null e['description'] != null
? e['description'] as String ? e['description'] as String
: tr('noDescription') : tr('noDescription'),
] ],
}); });
} }
return urlsWithDescriptions; return urlsWithDescriptions;
@ -41,15 +45,18 @@ class GitHubStars implements MassAppUrlSource {
@override @override
Future<Map<String, List<String>>> getUrlsWithDescriptions( Future<Map<String, List<String>>> getUrlsWithDescriptions(
List<String> args) async { List<String> args,
) async {
if (args.length != requiredArgs.length) { if (args.length != requiredArgs.length) {
throw ObtainiumError(tr('wrongArgNum')); throw ObtainiumError(tr('wrongArgNum'));
} }
Map<String, List<String>> urlsWithDescriptions = {}; Map<String, List<String>> urlsWithDescriptions = {};
var page = 1; var page = 1;
while (true) { while (true) {
var pageUrls = var pageUrls = await getOnePageOfUserStarredUrlsWithDescriptions(
await getOnePageOfUserStarredUrlsWithDescriptions(args[0], page++); args[0],
page++,
);
urlsWithDescriptions.addAll(pageUrls); urlsWithDescriptions.addAll(pageUrls);
if (pageUrls.length < 100) { if (pageUrls.length < 100) {
break; break;

File diff suppressed because it is too large Load Diff

View File

@ -40,13 +40,15 @@ class _AppPageState extends State<AppPage> {
onWebResourceError: (WebResourceError error) { onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) { if (error.isForMainFrame == true) {
showError( showError(
ObtainiumError(error.description, unexpected: true), context); ObtainiumError(error.description, unexpected: true),
context,
);
} }
}, },
onNavigationRequest: (NavigationRequest request) => onNavigationRequest: (NavigationRequest request) =>
request.url.startsWith("rustore://") request.url.startsWith("rustore://")
? NavigationDecision.prevent ? NavigationDecision.prevent
: NavigationDecision.navigate, : NavigationDecision.navigate,
), ),
); );
} }
@ -85,8 +87,10 @@ class _AppPageState extends State<AppPage> {
var sourceProvider = SourceProvider(); var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy(); AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy();
var source = app != null var source = app != null
? sourceProvider.getSource(app.app.url, ? sourceProvider.getSource(
overrideSource: app.app.overrideSource) app.app.url,
overrideSource: app.app.overrideSource,
)
: null; : null;
if (!areDownloadsRunning && if (!areDownloadsRunning &&
prevApp == null && prevApp == null &&
@ -100,7 +104,9 @@ class _AppPageState extends State<AppPage> {
bool isVersionDetectionStandard = bool isVersionDetectionStandard =
app?.app.additionalSettings['versionDetection'] == true; app?.app.additionalSettings['versionDetection'] == true;
bool installedVersionIsEstimate = app?.app != null ? isVersionPseudo(app!.app) : false; bool installedVersionIsEstimate = app?.app != null
? isVersionPseudo(app!.app)
: false;
if (app != null && !_wasWebViewOpened) { if (app != null && !_wasWebViewOpened) {
_wasWebViewOpened = true; _wasWebViewOpened = true;
@ -122,11 +128,14 @@ class _AppPageState extends State<AppPage> {
if (!upToDate) { if (!upToDate) {
versionLines += '\n${app?.app.latestVersion} ${tr('latest')}'; versionLines += '\n${app?.app.latestVersion} ${tr('latest')}';
} }
String infoLines = tr('lastUpdateCheckX', args: [ String infoLines = tr(
app?.app.lastUpdateCheck == null 'lastUpdateCheckX',
? tr('never') args: [
: '${app?.app.lastUpdateCheck?.toLocal()}' app?.app.lastUpdateCheck == null
]); ? tr('never')
: '${app?.app.lastUpdateCheck?.toLocal()}',
],
);
if (trackOnly) { if (trackOnly) {
infoLines = '${tr('xIsTrackOnly', args: [tr('app')])}\n$infoLines'; infoLines = '${tr('xIsTrackOnly', args: [tr('app')])}\n$infoLines';
} }
@ -146,15 +155,14 @@ class _AppPageState extends State<AppPage> {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
child: Column( child: Column(
children: [ children: [
const SizedBox( const SizedBox(height: 8),
height: 8, Text(
versionLines,
textAlign: TextAlign.start,
style: Theme.of(
context,
).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold),
), ),
Text(versionLines,
textAlign: TextAlign.start,
style: Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontWeight: FontWeight.bold)),
changeLogFn != null || app?.app.releaseDate != null changeLogFn != null || app?.app.releaseDate != null
? GestureDetector( ? GestureDetector(
onTap: changeLogFn, onTap: changeLogFn,
@ -163,21 +171,19 @@ class _AppPageState extends State<AppPage> {
? tr('changes') ? tr('changes')
: app!.app.releaseDate!.toLocal().toString(), : app!.app.releaseDate!.toLocal().toString(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: style: Theme.of(context).textTheme.labelSmall!
Theme.of(context).textTheme.labelSmall!.copyWith( .copyWith(
decoration: changeLogFn != null decoration: changeLogFn != null
? TextDecoration.underline ? TextDecoration.underline
: null, : null,
fontStyle: changeLogFn != null fontStyle: changeLogFn != null
? FontStyle.italic ? FontStyle.italic
: null, : null,
), ),
), ),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
const SizedBox( const SizedBox(height: 8),
height: 8,
),
], ],
), ),
), ),
@ -189,101 +195,108 @@ class _AppPageState extends State<AppPage> {
if (app?.app.apkUrls.isNotEmpty == true || if (app?.app.apkUrls.isNotEmpty == true ||
app?.app.otherAssetUrls.isNotEmpty == true) app?.app.otherAssetUrls.isNotEmpty == true)
GestureDetector( GestureDetector(
onTap: app?.app == null || updating onTap: app?.app == null || updating
? null ? null
: () async { : () async {
try { try {
await appsProvider await appsProvider.downloadAppAssets([
.downloadAppAssets([app!.app.id], context); app!.app.id,
} catch (e) { ], context);
showError(e, context); } catch (e) {
} showError(e, context);
}, }
child: Row( },
mainAxisAlignment: MainAxisAlignment.center, child: Row(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Container( children: [
decoration: BoxDecoration( Container(
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
color: settingsProvider.highlightTouchTargets borderRadius: BorderRadius.circular(12),
? (Theme.of(context).brightness == color: settingsProvider.highlightTouchTargets
Brightness.light ? (Theme.of(context).brightness == Brightness.light
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: Theme.of(context).primaryColorLight) : Theme.of(context).primaryColorLight)
.withAlpha(Theme.of(context).brightness == .withAlpha(
Brightness.light Theme.of(context).brightness ==
? 20 Brightness.light
: 40) ? 20
: null), : 40,
padding: settingsProvider.highlightTouchTargets )
? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6) : null,
: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6), ),
margin: padding: settingsProvider.highlightTouchTargets
const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0), ? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6)
child: Text( : const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6),
tr('downloadX', margin: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0),
args: [tr('releaseAsset').toLowerCase()]), child: Text(
textAlign: TextAlign.center, tr('downloadX', args: [tr('releaseAsset').toLowerCase()]),
style: textAlign: TextAlign.center,
Theme.of(context).textTheme.labelSmall!.copyWith( style: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
)) ),
], ),
)), ],
const SizedBox( ),
height: 48, ),
), const SizedBox(height: 48),
CategoryEditorSelector( CategoryEditorSelector(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
preselected: app?.app.categories != null preselected: app?.app.categories != null
? app!.app.categories.toSet() ? app!.app.categories.toSet()
: {}, : {},
onSelected: (categories) { onSelected: (categories) {
if (app != null) { if (app != null) {
app.app.categories = categories; app.app.categories = categories;
appsProvider.saveApps([app.app]); appsProvider.saveApps([app.app]);
} }
}), },
),
if (app?.app.additionalSettings['about'] is String && if (app?.app.additionalSettings['about'] is String &&
app?.app.additionalSettings['about'].isNotEmpty) app?.app.additionalSettings['about'].isNotEmpty)
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox( const SizedBox(height: 48),
height: 48,
),
GestureDetector( GestureDetector(
onLongPress: () { onLongPress: () {
Clipboard.setData(ClipboardData( Clipboard.setData(
text: app?.app.additionalSettings['about'] ?? '')); ClipboardData(
ScaffoldMessenger.of(context).showSnackBar(SnackBar( text: app?.app.additionalSettings['about'] ?? '',
content: Text(tr('copiedToClipboard')),
));
},
child: Markdown(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
styleSheet: MarkdownStyleSheet(
blockquoteDecoration:
BoxDecoration(color: Theme.of(context).cardColor),
textAlign: WrapAlignment.center),
data: app?.app.additionalSettings['about'],
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(href,
mode: LaunchMode.externalApplication);
}
},
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
[
md.EmojiSyntax(),
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes
],
), ),
)) );
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(tr('copiedToClipboard'))),
);
},
child: Markdown(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
styleSheet: MarkdownStyleSheet(
blockquoteDecoration: BoxDecoration(
color: Theme.of(context).cardColor,
),
textAlign: WrapAlignment.center,
),
data: app?.app.additionalSettings['about'],
onTapLink: (text, href, title) {
if (href != null) {
launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
}
},
extensionSet: md.ExtensionSet(
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
[
md.EmojiSyntax(),
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
],
),
),
),
], ],
), ),
], ],
@ -291,132 +304,143 @@ class _AppPageState extends State<AppPage> {
} }
getFullInfoColumn({bool small = false}) => Column( getFullInfoColumn({bool small = false}) => Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox(height: small ? 5 : 20), SizedBox(height: small ? 5 : 20),
FutureBuilder( FutureBuilder(
future: future: appsProvider.updateAppIcon(app?.app.id, ignoreCache: true),
appsProvider.updateAppIcon(app?.app.id, ignoreCache: true), builder: (ctx, val) {
builder: (ctx, val) { return app?.icon != null
return app?.icon != null ? Row(
? Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ GestureDetector(
GestureDetector( onTap: app == null
onTap: app == null ? null
? null : () => pm.openApp(app.app.id),
: () => pm.openApp(app.app.id), child: Image.memory(
child: Image.memory( app!.icon!,
app!.icon!, height: small ? 70 : 150,
height: small ? 70 : 150, gaplessPlayback: true,
gaplessPlayback: true, ),
), ),
) ],
]) )
: Container(); : Container();
}), },
SizedBox( ),
height: small ? 10 : 25, SizedBox(height: small ? 10 : 25),
Text(
app?.name ?? tr('app'),
textAlign: TextAlign.center,
style: small
? Theme.of(context).textTheme.displaySmall
: Theme.of(context).textTheme.displayLarge,
),
Text(
tr('byX', args: [app?.author ?? tr('unknown')]),
textAlign: TextAlign.center,
style: small
? Theme.of(context).textTheme.headlineSmall
: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(
app?.app.url ?? '',
mode: LaunchMode.externalApplication,
);
}
},
onLongPress: () {
Clipboard.setData(ClipboardData(text: app?.app.url ?? ''));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(tr('copiedToClipboard'))));
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
), ),
Text( ),
app?.name ?? tr('app'), ),
textAlign: TextAlign.center, Text(
style: small app?.app.id ?? '',
? Theme.of(context).textTheme.displaySmall textAlign: TextAlign.center,
: Theme.of(context).textTheme.displayLarge, style: Theme.of(context).textTheme.labelSmall,
), ),
Text(tr('byX', args: [app?.author ?? tr('unknown')]), getInfoColumn(),
textAlign: TextAlign.center, const SizedBox(height: 150),
style: small ],
? Theme.of(context).textTheme.headlineSmall );
: Theme.of(context).textTheme.headlineMedium),
const SizedBox(
height: 24,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
onLongPress: () {
Clipboard.setData(ClipboardData(text: app?.app.url ?? ''));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(tr('copiedToClipboard')),
));
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall!.copyWith(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic),
)),
Text(
app?.app.id ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
getInfoColumn(),
const SizedBox(height: 150)
],
);
getAppWebView() => app != null getAppWebView() => app != null
? WebViewWidget( ? WebViewWidget(
key: ObjectKey(_webViewController), key: ObjectKey(_webViewController),
controller: _webViewController controller: _webViewController
..setBackgroundColor(Theme.of(context).colorScheme.surface)) ..setBackgroundColor(Theme.of(context).colorScheme.surface),
)
: Container(); : Container();
showMarkUpdatedDialog() { showMarkUpdatedDialog() {
return showDialog( return showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: Text(tr('alreadyUpToDateQuestion')), title: Text(tr('alreadyUpToDateQuestion')),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: Text(tr('no'))), child: Text(tr('no')),
TextButton( ),
onPressed: () { TextButton(
HapticFeedback.selectionClick(); onPressed: () {
var updatedApp = app?.app; HapticFeedback.selectionClick();
if (updatedApp != null) { var updatedApp = app?.app;
updatedApp.installedVersion = updatedApp.latestVersion; if (updatedApp != null) {
appsProvider.saveApps([updatedApp]); updatedApp.installedVersion = updatedApp.latestVersion;
} appsProvider.saveApps([updatedApp]);
Navigator.of(context).pop(); }
}, Navigator.of(context).pop();
child: Text(tr('yesMarkUpdated'))) },
], child: Text(tr('yesMarkUpdated')),
); ),
}); ],
);
},
);
} }
showAdditionalOptionsDialog() async { showAdditionalOptionsDialog() async {
return await showDialog<Map<String, dynamic>?>( return await showDialog<Map<String, dynamic>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var items = var items = (source?.combinedAppSpecificSettingFormItems ?? []).map((
(source?.combinedAppSpecificSettingFormItems ?? []).map((row) { row,
row = row.map((e) { ) {
if (app?.app.additionalSettings[e.key] != null) { row = row.map((e) {
e.defaultValue = app?.app.additionalSettings[e.key]; if (app?.app.additionalSettings[e.key] != null) {
} e.defaultValue = app?.app.additionalSettings[e.key];
return e; }
}).toList(); return e;
return row;
}).toList(); }).toList();
return row;
}).toList();
return GeneratedFormModal( return GeneratedFormModal(
title: tr('additionalOptions'), items: items); title: tr('additionalOptions'),
}); items: items,
);
},
);
} }
handleAdditionalOptionChanges(Map<String, dynamic>? values) { handleAdditionalOptionChanges(Map<String, dynamic>? values) {
@ -430,18 +454,18 @@ class _AppPageState extends State<AppPage> {
} }
var versionDetectionEnabled = var versionDetectionEnabled =
app.app.additionalSettings['versionDetection'] == true && app.app.additionalSettings['versionDetection'] == true &&
originalSettings['versionDetection'] != true; originalSettings['versionDetection'] != true;
var releaseDateVersionEnabled = var releaseDateVersionEnabled =
app.app.additionalSettings['releaseDateAsVersion'] == true && app.app.additionalSettings['releaseDateAsVersion'] == true &&
originalSettings['releaseDateAsVersion'] != true; originalSettings['releaseDateAsVersion'] != true;
var releaseDateVersionDisabled = var releaseDateVersionDisabled =
app.app.additionalSettings['releaseDateAsVersion'] != true && app.app.additionalSettings['releaseDateAsVersion'] != true &&
originalSettings['releaseDateAsVersion'] == true; originalSettings['releaseDateAsVersion'] == true;
if (releaseDateVersionEnabled) { if (releaseDateVersionEnabled) {
if (app.app.releaseDate != null) { if (app.app.releaseDate != null) {
bool isUpdated = app.app.installedVersion == app.app.latestVersion; bool isUpdated = app.app.installedVersion == app.app.latestVersion;
app.app.latestVersion = app.app.latestVersion = app.app.releaseDate!.microsecondsSinceEpoch
app.app.releaseDate!.microsecondsSinceEpoch.toString(); .toString();
if (isUpdated) { if (isUpdated) {
app.app.installedVersion = app.app.latestVersion; app.app.installedVersion = app.app.latestVersion;
} }
@ -461,172 +485,195 @@ class _AppPageState extends State<AppPage> {
} }
getInstallOrUpdateButton() => TextButton( getInstallOrUpdateButton() => TextButton(
onPressed: !updating && onPressed:
(app?.app.installedVersion == null || !updating &&
app?.app.installedVersion != app?.app.latestVersion) && (app?.app.installedVersion == null ||
!areDownloadsRunning app?.app.installedVersion != app?.app.latestVersion) &&
? () async { !areDownloadsRunning
try { ? () async {
var successMessage = app?.app.installedVersion == null try {
? tr('installed') var successMessage = app?.app.installedVersion == null
: tr('appsUpdated'); ? tr('installed')
HapticFeedback.heavyImpact(); : tr('appsUpdated');
var res = await appsProvider.downloadAndInstallLatestApps( HapticFeedback.heavyImpact();
app?.app.id != null ? [app!.app.id] : [], var res = await appsProvider.downloadAndInstallLatestApps(
globalNavigatorKey.currentContext, app?.app.id != null ? [app!.app.id] : [],
); globalNavigatorKey.currentContext,
if (res.isNotEmpty && !trackOnly) { );
// ignore: use_build_context_synchronously if (res.isNotEmpty && !trackOnly) {
showMessage(successMessage, context);
}
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
} catch (e) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
showError(e, context); showMessage(successMessage, context);
} }
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
} catch (e) {
// ignore: use_build_context_synchronously
showError(e, context);
} }
: null, }
child: Text(app?.app.installedVersion == null : null,
child: Text(
app?.app.installedVersion == null
? !trackOnly ? !trackOnly
? tr('install') ? tr('install')
: tr('markInstalled') : tr('markInstalled')
: !trackOnly : !trackOnly
? tr('update') ? tr('update')
: tr('markUpdated'))); : tr('markUpdated'),
),
);
getBottomSheetMenu() => Padding( getBottomSheetMenu() => Padding(
padding: padding: EdgeInsets.fromLTRB(
EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), 0,
child: Column( 0,
mainAxisSize: MainAxisSize.min, 0,
children: [ MediaQuery.of(context).padding.bottom,
Padding( ),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Column(
child: Row( mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
children: [ Padding(
if (source != null && padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
source.combinedAppSpecificSettingFormItems.isNotEmpty) child: Row(
IconButton( mainAxisAlignment: MainAxisAlignment.spaceEvenly,
onPressed: app?.downloadProgress != null || updating children: [
? null if (source != null &&
: () async { source.combinedAppSpecificSettingFormItems.isNotEmpty)
var values = IconButton(
await showAdditionalOptionsDialog(); onPressed: app?.downloadProgress != null || updating
handleAdditionalOptionChanges(values); ? null
}, : () async {
tooltip: tr('additionalOptions'), var values = await showAdditionalOptionsDialog();
icon: const Icon(Icons.edit)), handleAdditionalOptionChanges(values);
if (app != null && app.installedInfo != null)
IconButton(
onPressed: () {
appsProvider.openAppSettings(app.app.id);
}, },
icon: const Icon(Icons.settings), tooltip: tr('additionalOptions'),
tooltip: tr('settings'), icon: const Icon(Icons.edit),
), ),
if (app != null && settingsProvider.showAppWebpage) if (app != null && app.installedInfo != null)
IconButton( IconButton(
onPressed: () { onPressed: () {
showDialog( appsProvider.openAppSettings(app.app.id);
context: context, },
builder: (BuildContext ctx) { icon: const Icon(Icons.settings),
return AlertDialog( tooltip: tr('settings'),
scrollable: true, ),
content: getFullInfoColumn(small: true), if (app != null && settingsProvider.showAppWebpage)
title: Text(app.name), IconButton(
actions: [ onPressed: () {
TextButton( showDialog(
onPressed: () { context: context,
Navigator.of(context).pop(); builder: (BuildContext ctx) {
}, return AlertDialog(
child: Text(tr('continue'))) scrollable: true,
], content: getFullInfoColumn(small: true),
); title: Text(app.name),
}); actions: [
}, TextButton(
icon: const Icon(Icons.more_horiz), onPressed: () {
tooltip: tr('more')), Navigator.of(context).pop();
if (app?.app.installedVersion != null && },
app?.app.installedVersion != app?.app.latestVersion && child: Text(tr('continue')),
!isVersionDetectionStandard && ),
!trackOnly) ],
IconButton( );
onPressed: app?.downloadProgress != null || updating },
? null );
: showMarkUpdatedDialog, },
tooltip: tr('markUpdated'), icon: const Icon(Icons.more_horiz),
icon: const Icon(Icons.done)), tooltip: tr('more'),
if ((!isVersionDetectionStandard || trackOnly) && ),
app?.app.installedVersion != null && if (app?.app.installedVersion != null &&
app?.app.installedVersion == app?.app.latestVersion) app?.app.installedVersion != app?.app.latestVersion &&
IconButton( !isVersionDetectionStandard &&
onPressed: app?.app == null || updating !trackOnly)
? null IconButton(
: () { onPressed: app?.downloadProgress != null || updating
app!.app.installedVersion = null; ? null
appsProvider.saveApps([app.app]); : showMarkUpdatedDialog,
}, tooltip: tr('markUpdated'),
icon: const Icon(Icons.restore_rounded), icon: const Icon(Icons.done),
tooltip: tr('resetInstallStatus')), ),
const SizedBox(width: 16.0), if ((!isVersionDetectionStandard || trackOnly) &&
Expanded(child: getInstallOrUpdateButton()), app?.app.installedVersion != null &&
const SizedBox(width: 16.0), app?.app.installedVersion == app?.app.latestVersion)
IconButton( IconButton(
onPressed: app?.downloadProgress != null || updating onPressed: app?.app == null || updating
? null ? null
: () { : () {
appsProvider app!.app.installedVersion = null;
.removeAppsWithModal( appsProvider.saveApps([app.app]);
context, app != null ? [app.app] : []) },
.then((value) { icon: const Icon(Icons.restore_rounded),
if (value == true) { tooltip: tr('resetInstallStatus'),
Navigator.of(context).pop(); ),
} const SizedBox(width: 16.0),
}); Expanded(child: getInstallOrUpdateButton()),
}, const SizedBox(width: 16.0),
tooltip: tr('remove'), IconButton(
icon: const Icon(Icons.delete_outline), onPressed: app?.downloadProgress != null || updating
), ? null
])), : () {
if (app?.downloadProgress != null) appsProvider
Padding( .removeAppsWithModal(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0), context,
child: LinearProgressIndicator( app != null ? [app.app] : [],
value: app!.downloadProgress! >= 0 )
? app.downloadProgress! / 100 .then((value) {
: null)) if (value == true) {
], Navigator.of(context).pop();
)); }
});
},
tooltip: tr('remove'),
icon: const Icon(Icons.delete_outline),
),
],
),
),
if (app?.downloadProgress != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: LinearProgressIndicator(
value: app!.downloadProgress! >= 0
? app.downloadProgress! / 100
: null,
),
),
],
),
);
appScreenAppBar() => AppBar( appScreenAppBar() => AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
); );
return Scaffold( return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(), appBar: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(),
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
child: settingsProvider.showAppWebpage child: settingsProvider.showAppWebpage
? getAppWebView() ? getAppWebView()
: CustomScrollView( : CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()])), child: Column(children: [getFullInfoColumn()]),
],
), ),
onRefresh: () async { ],
if (app != null) { ),
getUpdate(app.app.id); onRefresh: () async {
} if (app != null) {
}), getUpdate(app.app.id);
bottomSheet: getBottomSheetMenu()); }
},
),
bottomSheet: getBottomSheetMenu(),
);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -43,13 +43,22 @@ class _HomePageState extends State<HomePage> {
bool isLinkActivity = false; bool isLinkActivity = false;
List<NavigationPageItem> pages = [ List<NavigationPageItem> pages = [
NavigationPageItem(tr('appsString'), Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem( NavigationPageItem(
tr('addApp'), Icons.add, AddAppPage(key: GlobalKey<AddAppPageState>())), tr('appsString'),
Icons.apps,
AppsPage(key: GlobalKey<AppsPageState>()),
),
NavigationPageItem( NavigationPageItem(
tr('importExport'), Icons.import_export, const ImportExportPage()), tr('addApp'),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()) Icons.add,
AddAppPage(key: GlobalKey<AddAppPageState>()),
),
NavigationPageItem(
tr('importExport'),
Icons.import_export,
const ImportExportPage(),
),
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()),
]; ];
@override @override
@ -60,63 +69,69 @@ class _HomePageState extends State<HomePage> {
var sp = context.read<SettingsProvider>(); var sp = context.read<SettingsProvider>();
if (!sp.welcomeShown) { if (!sp.welcomeShown) {
await showDialog( await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: Text(tr('welcome')), title: Text(tr('welcome')),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 20, spacing: 20,
children: [ children: [
Text(tr('documentationLinksNote')), Text(tr('documentationLinksNote')),
GestureDetector( GestureDetector(
onTap: () {
launchUrlString(
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
mode: LaunchMode.externalApplication,
);
},
child: Text(
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tr('batteryOptimizationNote')),
GestureDetector(
onTap: () { onTap: () {
launchUrlString( final intent = AndroidIntent(
'https://github.com/ImranR98/Obtainium/blob/main/README.md', action:
mode: LaunchMode.externalApplication); 'android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS',
package:
obtainiumId, // Replace with your app's package name
);
intent.launch();
}, },
child: Text( child: Text(
'https://github.com/ImranR98/Obtainium/blob/main/README.md', tr('settings'),
style: const TextStyle( style: const TextStyle(
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
)),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(tr('batteryOptimizationNote')),
GestureDetector(
onTap: () {
final intent = AndroidIntent(
action:
'android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS',
package:
obtainiumId, // Replace with your app's package name
);
intent.launch();
},
child: Text(
tr('settings'),
style: const TextStyle(
decoration: TextDecoration.underline,
fontWeight: FontWeight.bold),
), ),
) ),
], ),
) ],
], ),
),
actions: [
TextButton(
onPressed: () {
sp.welcomeShown = true;
Navigator.of(context).pop(null);
},
child: Text(tr('ok'))),
], ],
); ),
}); actions: [
TextButton(
onPressed: () {
sp.welcomeShown = true;
Navigator.of(context).pop(null);
},
child: Text(tr('ok')),
),
],
);
},
);
} }
}); });
} }
@ -126,13 +141,12 @@ class _HomePageState extends State<HomePage> {
goToAddApp(String data) async { goToAddApp(String data) async {
switchToPage(1); switchToPage(1);
while ( while ((pages[1].widget.key as GlobalKey<AddAppPageState>?)
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState == ?.currentState ==
null) { null) {
await Future.delayed(const Duration(microseconds: 1)); await Future.delayed(const Duration(microseconds: 1));
} }
(pages[1].widget.key as GlobalKey<AddAppPageState>?) (pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState
?.currentState
?.linkFn(data); ?.linkFn(data);
} }
@ -146,44 +160,55 @@ class _HomePageState extends State<HomePage> {
} else if (action == 'app' || action == 'apps') { } else if (action == 'app' || action == 'apps') {
var dataStr = Uri.decodeComponent(data); var dataStr = Uri.decodeComponent(data);
if (await showDialog( if (await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return GeneratedFormModal(
title: tr('importX', args: [ title: tr(
action == 'app' ? tr('app') : tr('appsString') 'importX',
]), args: [
items: const [], (action == 'app' ? tr('app') : tr('appsString'))
additionalWidgets: [ .toLowerCase(),
ExpansionTile(
title: const Text('Raw JSON'),
children: [
Text(
dataStr,
style: const TextStyle(fontFamily: 'monospace'),
)
],
)
], ],
); ),
}) != items: const [],
additionalWidgets: [
ExpansionTile(
title: const Text('Raw JSON'),
children: [
Text(
dataStr,
style: const TextStyle(fontFamily: 'monospace'),
),
],
),
],
);
},
) !=
null) { null) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var result = await appsProvider.import(action == 'app' var result = await appsProvider.import(
? '{ "apps": [$dataStr] }' action == 'app'
: '{ "apps": $dataStr }'); ? '{ "apps": [$dataStr] }'
: '{ "apps": $dataStr }',
);
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
showMessage( showMessage(
tr('importedX', args: [plural('apps', result.key.length)]), tr(
context); 'importedX',
args: [plural('apps', result.key.length).toLowerCase()],
),
context,
);
await appsProvider await appsProvider
.checkUpdates(specificIds: result.key.map((e) => e.id).toList()) .checkUpdates(specificIds: result.key.map((e) => e.id).toList())
.catchError((e) { .catchError((e) {
if (e is Map && e['errors'] is MultiAppMultiError) { if (e is Map && e['errors'] is MultiAppMultiError) {
showError(e['errors'].toString(), context); showError(e['errors'].toString(), context);
} }
return <App>[]; return <App>[];
}); });
} }
} else { } else {
throw ObtainiumError(tr('unknown')); throw ObtainiumError(tr('unknown'));
@ -210,15 +235,16 @@ class _HomePageState extends State<HomePage> {
}); });
} }
setIsReversing(int targetIndex) { void setIsReversing(int targetIndex) {
bool reversing = selectedIndexHistory.isNotEmpty && bool reversing =
selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last > targetIndex; selectedIndexHistory.last > targetIndex;
setState(() { setState(() {
isReversing = reversing; isReversing = reversing;
}); });
} }
switchToPage(int index) async { Future<void> switchToPage(int index) async {
setIsReversing(index); setIsReversing(index);
if (index == 0) { if (index == 0) {
while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState != while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState !=
@ -259,65 +285,71 @@ class _HomePageState extends State<HomePage> {
prevIsLoading = appsProvider.loadingApps; prevIsLoading = appsProvider.loadingApps;
return WillPopScope( return WillPopScope(
child: Scaffold( child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: PageTransitionSwitcher( body: PageTransitionSwitcher(
duration: Duration( duration: Duration(
milliseconds: milliseconds: settingsProvider.disablePageTransitions ? 0 : 300,
settingsProvider.disablePageTransitions ? 0 : 300),
reverse: settingsProvider.reversePageTransitions
? !isReversing
: isReversing,
transitionBuilder: (
Widget child,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last)
.widget,
), ),
bottomNavigationBar: NavigationBar( reverse: settingsProvider.reversePageTransitions
destinations: pages ? !isReversing
.map((e) => : isReversing,
NavigationDestination(icon: Icon(e.icon), label: e.title)) transitionBuilder:
.toList(), (
onDestinationSelected: (int index) async { Widget child,
HapticFeedback.selectionClick(); Animation<double> animation,
switchToPage(index); Animation<double> secondaryAnimation,
}, ) {
selectedIndex: return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
), )
.widget,
), ),
onWillPop: () async { bottomNavigationBar: NavigationBar(
if (isLinkActivity && destinations: pages
selectedIndexHistory.length == 1 && .map(
selectedIndexHistory.last == 1) { (e) =>
return true; NavigationDestination(icon: Icon(e.icon), label: e.title),
} )
setIsReversing(selectedIndexHistory.length >= 2 .toList(),
onDestinationSelected: (int index) async {
HapticFeedback.selectionClick();
switchToPage(index);
},
selectedIndex: selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last,
),
),
onWillPop: () async {
if (isLinkActivity &&
selectedIndexHistory.length == 1 &&
selectedIndexHistory.last == 1) {
return true;
}
setIsReversing(
selectedIndexHistory.length >= 2
? selectedIndexHistory.reversed.toList()[1] ? selectedIndexHistory.reversed.toList()[1]
: 0); : 0,
if (selectedIndexHistory.isNotEmpty) { );
setState(() { if (selectedIndexHistory.isNotEmpty) {
selectedIndexHistory.removeLast(); setState(() {
}); selectedIndexHistory.removeLast();
return false; });
} return false;
return !(pages[0].widget.key as GlobalKey<AppsPageState>) }
.currentState return !(pages[0].widget.key as GlobalKey<AppsPageState>).currentState!
?.clearSelected(); .clearSelected();
}); },
);
} }
@override @override

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ class Log {
idColumn: id, idColumn: id,
levelColumn: level.index, levelColumn: level.index,
messageColumn: message, messageColumn: message,
timestampColumn: timestamp.millisecondsSinceEpoch timestampColumn: timestamp.millisecondsSinceEpoch,
}; };
return map; return map;
} }
@ -33,8 +33,9 @@ class Log {
id = map[idColumn] as int; id = map[idColumn] as int;
level = LogLevels.values.elementAt(map[levelColumn] as int); level = LogLevels.values.elementAt(map[levelColumn] as int);
message = map[messageColumn] as String; message = map[messageColumn] as String;
timestamp = timestamp = DateTime.fromMillisecondsSinceEpoch(
DateTime.fromMillisecondsSinceEpoch(map[timestampColumn] as int); map[timestampColumn] as int,
);
} }
@override @override
@ -51,16 +52,19 @@ class LogsProvider {
Database? db; Database? db;
Future<Database> getDB() async { Future<Database> getDB() async {
db ??= await openDatabase(dbPath, version: 1, db ??= await openDatabase(
onCreate: (Database db, int version) async { dbPath,
await db.execute(''' version: 1,
onCreate: (Database db, int version) async {
await db.execute('''
create table if not exists $logTable ( create table if not exists $logTable (
$idColumn integer primary key autoincrement, $idColumn integer primary key autoincrement,
$levelColumn integer not null, $levelColumn integer not null,
$messageColumn text not null, $messageColumn text not null,
$timestampColumn integer not null) $timestampColumn integer not null)
'''); ''');
}); },
);
return db!; return db!;
} }
@ -75,27 +79,38 @@ create table if not exists $logTable (
Future<List<Log>> get({DateTime? before, DateTime? after}) async { Future<List<Log>> get({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after); var where = getWhereDates(before: before, after: after);
return (await (await getDB()) return (await (await getDB()).query(
.query(logTable, where: where.key, whereArgs: where.value)) logTable,
.map((e) => Log.fromMap(e)) where: where.key,
.toList(); whereArgs: where.value,
)).map((e) => Log.fromMap(e)).toList();
} }
Future<int> clear({DateTime? before, DateTime? after}) async { Future<int> clear({DateTime? before, DateTime? after}) async {
var where = getWhereDates(before: before, after: after); var where = getWhereDates(before: before, after: after);
var res = await (await getDB()) var res = await (await getDB()).delete(
.delete(logTable, where: where.key, whereArgs: where.value); logTable,
where: where.key,
whereArgs: where.value,
);
if (res > 0) { if (res > 0) {
add(plural('clearedNLogsBeforeXAfterY', res, add(
plural(
'clearedNLogsBeforeXAfterY',
res,
namedArgs: {'before': before.toString(), 'after': after.toString()}, namedArgs: {'before': before.toString(), 'after': after.toString()},
name: 'n')); name: 'n',
),
);
} }
return res; return res;
} }
} }
MapEntry<String?, List<int>?> getWhereDates( MapEntry<String?, List<int>?> getWhereDates({
{DateTime? before, DateTime? after}) { DateTime? before,
DateTime? after,
}) {
List<String> where = []; List<String> where = [];
List<int> whereArgs = []; List<int> whereArgs = [];
if (before != null) { if (before != null) {

View File

@ -20,91 +20,116 @@ class ObtainiumNotification {
bool onlyAlertOnce; bool onlyAlertOnce;
String? payload; String? payload;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode, ObtainiumNotification(
this.channelName, this.channelDescription, this.importance, this.id,
{this.onlyAlertOnce = false, this.progPercent, this.payload}); this.title,
this.message,
this.channelCode,
this.channelName,
this.channelDescription,
this.importance, {
this.onlyAlertOnce = false,
this.progPercent,
this.payload,
});
} }
class UpdateNotification extends ObtainiumNotification { class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates, {int? id}) UpdateNotification(List<App> updates, {int? id})
: super( : super(
id ?? 2, id ?? 2,
tr('updatesAvailable'), tr('updatesAvailable'),
'', '',
'UPDATES_AVAILABLE', 'UPDATES_AVAILABLE',
tr('updatesAvailableNotifChannel'), tr('updatesAvailableNotifChannel'),
tr('updatesAvailableNotifDescription'), tr('updatesAvailableNotifDescription'),
Importance.max) { Importance.max,
) {
message = updates.isEmpty message = updates.isEmpty
? tr('noNewUpdates') ? tr('noNewUpdates')
: updates.length == 1 : updates.length == 1
? tr('xHasAnUpdate', args: [updates[0].finalName]) ? tr('xHasAnUpdate', args: [updates[0].finalName])
: plural('xAndNMoreUpdatesAvailable', updates.length - 1, : plural(
args: [updates[0].finalName, (updates.length - 1).toString()]); 'xAndNMoreUpdatesAvailable',
updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()],
);
} }
} }
class SilentUpdateNotification extends ObtainiumNotification { class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates, bool succeeded, {int? id}) SilentUpdateNotification(List<App> updates, bool succeeded, {int? id})
: super( : super(
id ?? 3, id ?? 3,
succeeded ? tr('appsUpdated') : tr('appsNotUpdated'), succeeded ? tr('appsUpdated') : tr('appsNotUpdated'),
'', '',
'APPS_UPDATED', 'APPS_UPDATED',
tr('appsUpdatedNotifChannel'), tr('appsUpdatedNotifChannel'),
tr('appsUpdatedNotifDescription'), tr('appsUpdatedNotifDescription'),
Importance.defaultImportance) { Importance.defaultImportance,
) {
message = updates.length == 1 message = updates.length == 1
? tr(succeeded ? 'xWasUpdatedToY' : 'xWasNotUpdatedToY', ? tr(
args: [updates[0].finalName, updates[0].latestVersion]) succeeded ? 'xWasUpdatedToY' : 'xWasNotUpdatedToY',
args: [updates[0].finalName, updates[0].latestVersion],
)
: plural( : plural(
succeeded ? 'xAndNMoreUpdatesInstalled' : "xAndNMoreUpdatesFailed", succeeded ? 'xAndNMoreUpdatesInstalled' : "xAndNMoreUpdatesFailed",
updates.length - 1, updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()]); args: [updates[0].finalName, (updates.length - 1).toString()],
);
} }
} }
class SilentUpdateAttemptNotification extends ObtainiumNotification { class SilentUpdateAttemptNotification extends ObtainiumNotification {
SilentUpdateAttemptNotification(List<App> updates, {int? id}) SilentUpdateAttemptNotification(List<App> updates, {int? id})
: super( : super(
id ?? 3, id ?? 3,
tr('appsPossiblyUpdated'), tr('appsPossiblyUpdated'),
'', '',
'APPS_POSSIBLY_UPDATED', 'APPS_POSSIBLY_UPDATED',
tr('appsPossiblyUpdatedNotifChannel'), tr('appsPossiblyUpdatedNotifChannel'),
tr('appsPossiblyUpdatedNotifDescription'), tr('appsPossiblyUpdatedNotifDescription'),
Importance.defaultImportance) { Importance.defaultImportance,
) {
message = updates.length == 1 message = updates.length == 1
? tr('xWasPossiblyUpdatedToY', ? tr(
args: [updates[0].finalName, updates[0].latestVersion]) 'xWasPossiblyUpdatedToY',
: plural('xAndNMoreUpdatesPossiblyInstalled', updates.length - 1, args: [updates[0].finalName, updates[0].latestVersion],
args: [updates[0].finalName, (updates.length - 1).toString()]); )
: plural(
'xAndNMoreUpdatesPossiblyInstalled',
updates.length - 1,
args: [updates[0].finalName, (updates.length - 1).toString()],
);
} }
} }
class ErrorCheckingUpdatesNotification extends ObtainiumNotification { class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error, {int? id}) ErrorCheckingUpdatesNotification(String error, {int? id})
: super( : super(
id ?? 5, id ?? 5,
tr('errorCheckingUpdates'), tr('errorCheckingUpdates'),
error, error,
'BG_UPDATE_CHECK_ERROR', 'BG_UPDATE_CHECK_ERROR',
tr('errorCheckingUpdatesNotifChannel'), tr('errorCheckingUpdatesNotifChannel'),
tr('errorCheckingUpdatesNotifDescription'), tr('errorCheckingUpdatesNotifDescription'),
Importance.high, Importance.high,
payload: "${tr('errorCheckingUpdates')}\n$error"); payload: "${tr('errorCheckingUpdates')}\n$error",
);
} }
class AppsRemovedNotification extends ObtainiumNotification { class AppsRemovedNotification extends ObtainiumNotification {
AppsRemovedNotification(List<List<String>> namedReasons) AppsRemovedNotification(List<List<String>> namedReasons)
: super( : super(
6, 6,
tr('appsRemoved'), tr('appsRemoved'),
'', '',
'APPS_REMOVED', 'APPS_REMOVED',
tr('appsRemovedNotifChannel'), tr('appsRemovedNotifChannel'),
tr('appsRemovedNotifDescription'), tr('appsRemovedNotifDescription'),
Importance.max) { Importance.max,
) {
message = ''; message = '';
for (var r in namedReasons) { for (var r in namedReasons) {
message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n'; message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n';
@ -115,49 +140,53 @@ class AppsRemovedNotification extends ObtainiumNotification {
class DownloadNotification extends ObtainiumNotification { class DownloadNotification extends ObtainiumNotification {
DownloadNotification(String appName, int progPercent) DownloadNotification(String appName, int progPercent)
: super( : super(
appName.hashCode, appName.hashCode,
tr('downloadingX', args: [appName]), tr('downloadingX', args: [appName]),
'', '',
'APP_DOWNLOADING', 'APP_DOWNLOADING',
tr('downloadingXNotifChannel', args: [tr('app')]), tr('downloadingXNotifChannel', args: [tr('app')]),
tr('downloadNotifDescription'), tr('downloadNotifDescription'),
Importance.low, Importance.low,
onlyAlertOnce: true, onlyAlertOnce: true,
progPercent: progPercent); progPercent: progPercent,
);
} }
class DownloadedNotification extends ObtainiumNotification { class DownloadedNotification extends ObtainiumNotification {
DownloadedNotification(String fileName, String downloadUrl) DownloadedNotification(String fileName, String downloadUrl)
: super( : super(
downloadUrl.hashCode, downloadUrl.hashCode,
tr('downloadedX', args: [fileName]), tr('downloadedX', args: [fileName]),
'', '',
'FILE_DOWNLOADED', 'FILE_DOWNLOADED',
tr('downloadedXNotifChannel', args: [tr('app')]), tr('downloadedXNotifChannel', args: [tr('app')]),
tr('downloadedX', args: [tr('app')]), tr('downloadedX', args: [tr('app')]),
Importance.defaultImportance); Importance.defaultImportance,
);
} }
final completeInstallationNotification = ObtainiumNotification( final completeInstallationNotification = ObtainiumNotification(
1, 1,
tr('completeAppInstallation'), tr('completeAppInstallation'),
tr('obtainiumMustBeOpenToInstallApps'), tr('obtainiumMustBeOpenToInstallApps'),
'COMPLETE_INSTALL', 'COMPLETE_INSTALL',
tr('completeAppInstallationNotifChannel'), tr('completeAppInstallationNotifChannel'),
tr('completeAppInstallationNotifDescription'), tr('completeAppInstallationNotifDescription'),
Importance.max); Importance.max,
);
class CheckingUpdatesNotification extends ObtainiumNotification { class CheckingUpdatesNotification extends ObtainiumNotification {
CheckingUpdatesNotification(String appName) CheckingUpdatesNotification(String appName)
: super( : super(
4, 4,
tr('checkingForUpdates'), tr('checkingForUpdates'),
appName, appName,
'BG_UPDATE_CHECK', 'BG_UPDATE_CHECK',
tr('checkingForUpdatesNotifChannel'), tr('checkingForUpdatesNotifChannel'),
tr('checkingForUpdatesNotifDescription'), tr('checkingForUpdatesNotifDescription'),
Importance.min); Importance.min,
);
} }
class NotificationsProvider { class NotificationsProvider {
@ -173,13 +202,15 @@ class NotificationsProvider {
Importance.max: Priority.max, Importance.max: Priority.max,
Importance.min: Priority.min, Importance.min: Priority.min,
Importance.none: Priority.min, Importance.none: Priority.min,
Importance.unspecified: Priority.defaultPriority Importance.unspecified: Priority.defaultPriority,
}; };
Future<void> initialize() async { Future<void> initialize() async {
isInitialized = await notifications.initialize( isInitialized =
await notifications.initialize(
const InitializationSettings( const InitializationSettings(
android: AndroidInitializationSettings('ic_notification')), android: AndroidInitializationSettings('ic_notification'),
),
onDidReceiveNotificationResponse: (NotificationResponse response) { onDidReceiveNotificationResponse: (NotificationResponse response) {
_showNotificationPayload(response.payload); _showNotificationPayload(response.payload);
}, },
@ -187,16 +218,18 @@ class NotificationsProvider {
false; false;
} }
checkLaunchByNotif() async { Future<void> checkLaunchByNotif() async {
final NotificationAppLaunchDetails? launchDetails = final NotificationAppLaunchDetails? launchDetails = await notifications
await notifications.getNotificationAppLaunchDetails(); .getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) { if (launchDetails?.didNotificationLaunchApp ?? false) {
_showNotificationPayload(launchDetails!.notificationResponse?.payload, _showNotificationPayload(
doublePop: true); launchDetails!.notificationResponse?.payload,
doublePop: true,
);
} }
} }
_showNotificationPayload(String? payload, {bool doublePop = false}) { void _showNotificationPayload(String? payload, {bool doublePop = false}) {
if (payload?.isNotEmpty == true) { if (payload?.isNotEmpty == true) {
var title = (payload ?? '\n\n').split('\n').first; var title = (payload ?? '\n\n').split('\n').first;
var content = (payload ?? '\n\n').split('\n').sublist(1).join('\n'); var content = (payload ?? '\n\n').split('\n').sublist(1).join('\n');
@ -207,13 +240,14 @@ class NotificationsProvider {
content: Text(content), content: Text(content),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(null);
if (doublePop) {
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
if (doublePop) { }
Navigator.of(context).pop(null); },
} child: Text(tr('ok')),
}, ),
child: Text(tr('ok'))),
], ],
), ),
), ),
@ -229,17 +263,18 @@ class NotificationsProvider {
} }
Future<void> notifyRaw( Future<void> notifyRaw(
int id, int id,
String title, String title,
String message, String message,
String channelCode, String channelCode,
String channelName, String channelName,
String channelDescription, String channelDescription,
Importance importance, Importance importance, {
{bool cancelExisting = false, bool cancelExisting = false,
int? progPercent, int? progPercent,
bool onlyAlertOnce = false, bool onlyAlertOnce = false,
String? payload}) async { String? payload,
}) async {
if (cancelExisting) { if (cancelExisting) {
await cancel(id); await cancel(id);
} }
@ -247,29 +282,42 @@ class NotificationsProvider {
await initialize(); await initialize();
} }
await notifications.show( await notifications.show(
id, id,
title, title,
message, message,
NotificationDetails( NotificationDetails(
android: AndroidNotificationDetails(channelCode, channelName, android: AndroidNotificationDetails(
channelDescription: channelDescription, channelCode,
importance: importance, channelName,
priority: importanceToPriority[importance]!, channelDescription: channelDescription,
groupKey: '$obtainiumId.$channelCode', importance: importance,
progress: progPercent ?? 0, priority: importanceToPriority[importance]!,
maxProgress: 100, groupKey: '$obtainiumId.$channelCode',
showProgress: progPercent != null, progress: progPercent ?? 0,
onlyAlertOnce: onlyAlertOnce, maxProgress: 100,
indeterminate: progPercent != null && progPercent < 0)), showProgress: progPercent != null,
payload: payload); onlyAlertOnce: onlyAlertOnce,
indeterminate: progPercent != null && progPercent < 0,
),
),
payload: payload,
);
} }
Future<void> notify(ObtainiumNotification notif, Future<void> notify(
{bool cancelExisting = false}) => ObtainiumNotification notif, {
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode, bool cancelExisting = false,
notif.channelName, notif.channelDescription, notif.importance, }) => notifyRaw(
cancelExisting: cancelExisting, notif.id,
onlyAlertOnce: notif.onlyAlertOnce, notif.title,
progPercent: notif.progPercent, notif.message,
payload: notif.payload); notif.channelCode,
notif.channelName,
notif.channelDescription,
notif.importance,
cancelExisting: cancelExisting,
onlyAlertOnce: notif.onlyAlertOnce,
progPercent: notif.progPercent,
payload: notif.payload,
);
} }

View File

@ -58,8 +58,8 @@ class SettingsProvider with ChangeNotifier {
} }
ThemeSettings get theme { ThemeSettings get theme {
return ThemeSettings return ThemeSettings.values[prefs?.getInt('theme') ??
.values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; ThemeSettings.system.index];
} }
set theme(ThemeSettings t) { set theme(ThemeSettings t) {
@ -123,8 +123,8 @@ class SettingsProvider with ChangeNotifier {
} }
SortColumnSettings get sortColumn { SortColumnSettings get sortColumn {
return SortColumnSettings.values[ return SortColumnSettings.values[prefs?.getInt('sortColumn') ??
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index]; SortColumnSettings.nameAuthor.index];
} }
set sortColumn(SortColumnSettings s) { set sortColumn(SortColumnSettings s) {
@ -133,8 +133,8 @@ class SettingsProvider with ChangeNotifier {
} }
SortOrderSettings get sortOrder { SortOrderSettings get sortOrder {
return SortOrderSettings.values[ return SortOrderSettings.values[prefs?.getInt('sortOrder') ??
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index]; SortOrderSettings.ascending.index];
} }
set sortOrder(SortOrderSettings s) { set sortOrder(SortOrderSettings s) {
@ -171,7 +171,9 @@ class SettingsProvider with ChangeNotifier {
while (!(await Permission.requestInstallPackages.isGranted)) { while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged // Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast( Fluttertoast.showToast(
msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG); msg: tr('pleaseAllowInstallPerm'),
toastLength: Toast.LENGTH_LONG,
);
if ((await Permission.requestInstallPackages.request()) == if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) { PermissionStatus.granted) {
return true; return true;
@ -470,7 +472,8 @@ class SettingsProvider with ChangeNotifier {
} }
List<String> get searchDeselected { List<String> get searchDeselected {
return prefs?.getStringList('searchDeselected') ?? SourceProvider().sources.map((s) => s.name).toList(); return prefs?.getStringList('searchDeselected') ??
SourceProvider().sources.map((s) => s.name).toList();
} }
set searchDeselected(List<String> list) { set searchDeselected(List<String> list) {

File diff suppressed because it is too large Load Diff

View File

@ -288,10 +288,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "77f8e81d22d2a07d0dee2c62e1dda71dc1da73bf43bb2d45af09727406167964" sha256: ef9908739bdd9c476353d6adff72e88fd00c625f5b959ae23f7567bd5137db0a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.1.9" version: "10.2.0"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -804,10 +804,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pointer_interceptor_web name: pointer_interceptor_web
sha256: "7a7087782110f8c1827170660b09f8aa893e0e9a61431dbbe2ac3fc482e8c044" sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.2+1" version: "0.10.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -893,7 +893,7 @@ packages:
description: description:
path: "." path: "."
ref: master ref: master
resolved-ref: "89cdb5434a7ac7510f6bcdb60e1d51a27ee2f40b" resolved-ref: "012e22791138958e089f6c1a8d6c4c6943a9f253"
url: "https://github.com/AlexBacich/shared-storage" url: "https://github.com/AlexBacich/shared-storage"
source: git source: git
version: "0.7.0" version: "0.7.0"
@ -1155,10 +1155,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
sha256: "7cb32b21825bd65569665c32bb00a34ded5779786d6201f5350979d2d529940d" sha256: f0dc2dc3a2b1e3a6abdd6801b9355ebfeb3b8f6cde6b9dc7c9235909c4a1f147
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.1"
webview_flutter_wkwebview: webview_flutter_wkwebview:
dependency: transitive dependency: transitive
description: description:
@ -1171,10 +1171,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: win32 name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.13.0" version: "5.14.0"
win32_registry: win32_registry:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.56+2313 version: 1.1.57+2314
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1