mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-30 21:13:28 +01:00 
			
		
		
		
	Merge pull request #535 from ImranR98/dev
Add APKPure (#531), SourceHut (#483), GitLab Search (#422), Sorting Option (#264), Bug Workaround (#534)
This commit is contained in:
		| @@ -15,7 +15,9 @@ Currently supported App sources: | ||||
| - [Mullvad](https://mullvad.net/en/) | ||||
| - [Signal](https://signal.org/) | ||||
| - [SourceForge](https://sourceforge.net/) | ||||
| - [SourceHut](https://git.sr.ht/) | ||||
| - [APKMirror](https://apkmirror.com/) (Track-Only) | ||||
| - [APKPure](https://apkpure.com/) | ||||
| - Third Party F-Droid Repos | ||||
| - Jenkins Jobs | ||||
| - [Steam](https://store.steampowered.com/mobile) | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "GitHub Personal Access Token (Erhöht das Ratenlimit)", | ||||
|     "githubPATHint": "PAT muss in diesem Format sein: Benutzername:Token", | ||||
|     "githubPATFormat": "Benutzername:Token", | ||||
|     "githubPATLinkText": "Über GitHub PATs", | ||||
|     "includePrereleases": "Vorabversionen einbeziehen", | ||||
|     "fallbackToOlderReleases": "Fallback auf ältere Versionen", | ||||
|     "filterReleaseTitlesByRegEx": "Release-Titel nach regulärem Ausdruck\nfiltern", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "Nicht noch einmal zeigen", | ||||
|     "dontShowTrackOnlyWarnings": "Warnung für 'Nur Nachverfolgen' nicht anzeigen", | ||||
|     "dontShowAPKOriginWarnings": "Warnung für APK-Herkunft nicht anzeigen", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "App entfernen?", | ||||
|         "other": "Apps entfernen?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)", | ||||
|     "githubPATHint": "PAT must be in this format: username:token", | ||||
|     "githubPATFormat": "username:token", | ||||
|     "githubPATLinkText": "About GitHub PATs", | ||||
|     "includePrereleases": "Include prereleases", | ||||
|     "fallbackToOlderReleases": "Fallback to older releases", | ||||
|     "filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "Don't show this again", | ||||
|     "dontShowTrackOnlyWarnings": "Don't Show 'Track-Only' Warnings", | ||||
|     "dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Remove App?", | ||||
|         "other": "Remove Apps?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "Token de Acceso Personal de GitHub (Reduce tiempos de espera)", | ||||
|     "githubPATHint": "El TAP debe tener este formato: nombre_de_usuario:token", | ||||
|     "githubPATFormat": "nombre_de_usuario:token", | ||||
|     "githubPATLinkText": "Sobre los TAP de GitHub", | ||||
|     "includePrereleases": "Incluir versiones preliminares", | ||||
|     "fallbackToOlderReleases": "Retorceder a versiones previas", | ||||
|     "filterReleaseTitlesByRegEx": "Filtra Títulos de Versiones mediantes Expresiones Regulares", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "No mostrar de nuevo", | ||||
|     "dontShowTrackOnlyWarnings": "No mostrar avisos de 'Solo Seguimiento'", | ||||
|     "dontShowAPKOriginWarnings": "No mostrar avisos de las fuentes de las APks", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "¿Eliminar Aplicación?", | ||||
|         "other": "¿Eliminar Aplicaciones?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "توکن دسترسی شخصی گیت هاب(محدودیت نرخ را افزایش میدهد)", | ||||
|     "githubPATHint": "PAT باید در این قالب باشد: username:token", | ||||
|     "githubPATFormat": "username:token", | ||||
|     "githubPATLinkText": "درباره گیتهاب PATs", | ||||
|     "includePrereleases": "شامل نسخه های اولیه", | ||||
|     "fallbackToOlderReleases": "بازگشت به نسخه های قدیمی تر", | ||||
|     "filterReleaseTitlesByRegEx": "عناوین انتشار را با بیان منظم فیلتر کنید", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "دوباره این را نشان نده", | ||||
|     "dontShowTrackOnlyWarnings": "هشدار 'فقط ردیابی' را نشان ندهید", | ||||
|     "dontShowAPKOriginWarnings": "هشدارهای منبع APK را نشان ندهید", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "برنامه حذف شود؟", | ||||
|         "other": "برنامه ها حذف شوند؟" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "Jeton d'Accès Personnel GitHub (Augmente la limite de débit)", | ||||
|     "githubPATHint": "Le JAP doit être dans ce format : username:token", | ||||
|     "githubPATFormat": "username:token", | ||||
|     "githubPATLinkText": "À propos des JAP GitHub", | ||||
|     "includePrereleases": "Inclure les avant-premières", | ||||
|     "fallbackToOlderReleases": "Retour aux anciennes versions", | ||||
|     "filterReleaseTitlesByRegEx": "Filtrer les titres de version par expression régulière", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "Don't show this again", | ||||
|     "dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning", | ||||
|     "dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Supprimer l'application ?", | ||||
|         "other": "Supprimer les applications ?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "GitHub személyes hozzáférési token (megnöveli a díjkorlátot)", | ||||
|     "githubPATHint": "A PAT-nak a következő formátumban kell lennie: felhasználónév:token", | ||||
|     "githubPATFormat": "felhasználónév:token", | ||||
|     "githubPATLinkText": "A GitHub PAT-okról", | ||||
|     "includePrereleases": "Tartalmazza az előzetes kiadásokat", | ||||
|     "fallbackToOlderReleases": "Visszatérés a régebbi kiadásokhoz", | ||||
|     "filterReleaseTitlesByRegEx": "A kiadás címeinek szűrése reguláris kifejezéssel", | ||||
| @@ -227,6 +226,10 @@ | ||||
|     "dontShowAgain": "Ne mutassa ezt újra", | ||||
|     "dontShowTrackOnlyWarnings": "Ne jelenítsen meg 'Csak nyomon követés' figyelmeztetést", | ||||
|     "dontShowAPKOriginWarnings": "Ne jelenítsen meg az APK eredetére vonatkozó figyelmeztetéseket", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Eltávolítja az alkalmazást?", | ||||
|         "other": "Eltávolítja az alkalmazást?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "GitHub Personal Access Token (diminuisce limite di traffico)", | ||||
|     "githubPATHint": "PAT deve seguire questo formato: username:token", | ||||
|     "githubPATFormat": "username:token", | ||||
|     "githubPATLinkText": "Informazioni su GitHub PAT", | ||||
|     "includePrereleases": "Includi prerelease", | ||||
|     "fallbackToOlderReleases": "Ripiega su release precedenti", | ||||
|     "filterReleaseTitlesByRegEx": "Filtra release con espressioni regolari", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "Don't show this again", | ||||
|     "dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning", | ||||
|     "dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Rimuovere l'App?", | ||||
|         "other": "Rimuovere le App?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "GitHub パーソナルアクセストークン (レート制限の引き上げ)", | ||||
|     "githubPATHint": "PATは次の形式でなければなりません: ユーザー名:トークン", | ||||
|     "githubPATFormat": "ユーザー名:トークン", | ||||
|     "githubPATLinkText": "GitHub PATsについて", | ||||
|     "includePrereleases": "プレリリースを含む", | ||||
|     "fallbackToOlderReleases": "旧リリースへのフォールバック", | ||||
|     "filterReleaseTitlesByRegEx": "正規表現でリリースタイトルを絞り込む", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "二度と表示しない", | ||||
|     "dontShowTrackOnlyWarnings": "「追跡のみ」の警告を表示しない", | ||||
|     "dontShowAPKOriginWarnings": "APK Originの警告を表示しない", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "アプリを削除しますか?", | ||||
|         "other": "アプリを削除しますか?" | ||||
|   | ||||
| @@ -20,7 +20,6 @@ | ||||
|     "githubPATLabel": "GitHub 个人访问令牌(提升 API 请求限额)", | ||||
|     "githubPATHint": "个人访问令牌必须为“username:token”的格式", | ||||
|     "githubPATFormat": "username:token", | ||||
|     "githubPATLinkText": "关于 GitHub 个人访问令牌", | ||||
|     "includePrereleases": "包含预发行版", | ||||
|     "fallbackToOlderReleases": "将旧发行版作为备选", | ||||
|     "filterReleaseTitlesByRegEx": "使用正则表达式筛选发行标题", | ||||
| @@ -228,6 +227,10 @@ | ||||
|     "dontShowAgain": "Don't show this again", | ||||
|     "dontShowTrackOnlyWarnings": "Don't Show the 'Track-Only' Warning", | ||||
|     "dontShowAPKOriginWarnings": "Don't Show APK Origin Warnings", | ||||
|     "moveNonInstalledAppsToBottom": "Move Non-Installed Apps to Bottom of Apps View", | ||||
|     "gitlabPATLabel": "GitLab Personal Access Token (Enables Search)", | ||||
|     "about": "About", | ||||
|     "requiresCredentialsInSettings": "This needs additional credentials (in Settings)", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "是否删除应用?", | ||||
|         "other": "是否删除应用?" | ||||
|   | ||||
							
								
								
									
										116
									
								
								lib/app_sources/apkcombo.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								lib/app_sources/apkcombo.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class APKCombo extends AppSource { | ||||
|   APKCombo() { | ||||
|     host = 'apkcombo.com'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/+[^/]+/+[^/]+'); | ||||
|     var match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw InvalidURLError(name); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? tryInferringAppId(String standardUrl, | ||||
|       {Map<String, dynamic> additionalSettings = const {}}) { | ||||
|     return Uri.parse(standardUrl).pathSegments.last; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Map<String, String> get requestHeaders => { | ||||
|         "User-Agent": "curl/8.0.1", | ||||
|         "Accept": "*/*", | ||||
|         "Connection": "keep-alive", | ||||
|         "Host": "$host" | ||||
|       }; | ||||
|  | ||||
|   Future<List<MapEntry<String, String>>> getApkUrls(String standardUrl) async { | ||||
|     var res = await sourceRequest('$standardUrl/download/apk'); | ||||
|     if (res.statusCode != 200) { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|     var html = parse(res.body); | ||||
|     return html | ||||
|         .querySelectorAll('#variants-tab > div > ul > li') | ||||
|         .map((e) { | ||||
|           String? arch = e | ||||
|               .querySelector('code') | ||||
|               ?.text | ||||
|               .trim() | ||||
|               .replaceAll(',', '') | ||||
|               .replaceAll(':', '-') | ||||
|               .replaceAll(' ', '-'); | ||||
|           return e.querySelectorAll('a').map((e) { | ||||
|             String? url = e.attributes['href']; | ||||
|             if (url != null && | ||||
|                 !Uri.parse(url).path.toLowerCase().endsWith('.apk')) { | ||||
|               url = null; | ||||
|             } | ||||
|             String verCode = | ||||
|                 e.querySelector('.info .header .vercode')?.text.trim() ?? ''; | ||||
|             return MapEntry<String, String>( | ||||
|                 arch != null ? '$arch-$verCode.apk' : '', url ?? ''); | ||||
|           }).toList(); | ||||
|         }) | ||||
|         .reduce((value, element) => [...value, ...element]) | ||||
|         .where((element) => element.value.isNotEmpty) | ||||
|         .toList(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier( | ||||
|       String apkUrl, String standardUrl) async { | ||||
|     var freshURLs = await getApkUrls(standardUrl); | ||||
|     var path2Match = Uri.parse(apkUrl).path; | ||||
|     for (var url in freshURLs) { | ||||
|       if (Uri.parse(url.value).path == path2Match) { | ||||
|         return url.value; | ||||
|       } | ||||
|     } | ||||
|     throw NoAPKError(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     String appId = tryInferringAppId(standardUrl)!; | ||||
|     String host = Uri.parse(standardUrl).host; | ||||
|     var preres = await sourceRequest(standardUrl); | ||||
|     if (preres.statusCode != 200) { | ||||
|       throw getObtainiumHttpError(preres); | ||||
|     } | ||||
|     var res = parse(preres.body); | ||||
|     String? version = res.querySelector('div.version')?.text.trim(); | ||||
|     if (version == null) { | ||||
|       throw NoVersionError(); | ||||
|     } | ||||
|     String appName = res.querySelector('div.app_name')?.text.trim() ?? appId; | ||||
|     String author = res.querySelector('div.author')?.text.trim() ?? appName; | ||||
|     List<String> infoArray = res | ||||
|         .querySelectorAll('div.information-table > .item > div.value') | ||||
|         .map((e) => e.text.trim()) | ||||
|         .toList(); | ||||
|     DateTime? releaseDate; | ||||
|     if (infoArray.length >= 2) { | ||||
|       try { | ||||
|         releaseDate = DateFormat('MMMM d, yyyy').parse(infoArray[1]); | ||||
|       } catch (e) { | ||||
|         // ignore | ||||
|       } | ||||
|     } | ||||
|     return APKDetails( | ||||
|         version, await getApkUrls(standardUrl), AppNames(author, appName), | ||||
|         releaseDate: releaseDate); | ||||
|   } | ||||
| } | ||||
| @@ -57,7 +57,7 @@ class APKMirror extends AppSource { | ||||
|                 true | ||||
|             ? additionalSettings['filterReleaseTitlesByRegEx'] | ||||
|             : null; | ||||
|     Response res = await get(Uri.parse('$standardUrl/feed')); | ||||
|     Response res = await sourceRequest('$standardUrl/feed'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var items = parse(res.body).querySelectorAll('item'); | ||||
|       dynamic targetRelease; | ||||
|   | ||||
							
								
								
									
										69
									
								
								lib/app_sources/apkpure.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								lib/app_sources/apkpure.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class APKPure extends AppSource { | ||||
|   APKPure() { | ||||
|     host = 'apkpure.com'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     RegExp standardUrlRegExB = RegExp('^https?://m.$host/+[^/]+/+[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); | ||||
|     if (match != null) { | ||||
|       url = 'https://$host/${Uri.parse(url).path}'; | ||||
|     } | ||||
|     RegExp standardUrlRegExA = RegExp('^https?://$host/+[^/]+/+[^/]+'); | ||||
|     match = standardUrlRegExA.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw InvalidURLError(name); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? tryInferringAppId(String standardUrl, | ||||
|       {Map<String, dynamic> additionalSettings = const {}}) { | ||||
|     return Uri.parse(standardUrl).pathSegments.last; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     String appId = tryInferringAppId(standardUrl)!; | ||||
|     String host = Uri.parse(standardUrl).host; | ||||
|     var res = await sourceRequest('$standardUrl/download'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var html = parse(res.body); | ||||
|       String? version = html.querySelector('span.info-sdk span')?.text.trim(); | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       String? dateString = | ||||
|           html.querySelector('span.info-other span.date')?.text.trim(); | ||||
|       DateTime? releaseDate = dateString != null | ||||
|           ? DateFormat('MMM dd, yyyy').parse(dateString) | ||||
|           : null; | ||||
|       List<MapEntry<String, String>> apkUrls = [ | ||||
|         MapEntry('$appId.apk', 'https://d.$host/b/APK/$appId?version=latest') | ||||
|       ]; | ||||
|       String author = html | ||||
|               .querySelector('span.info-sdk') | ||||
|               ?.text | ||||
|               .trim() | ||||
|               .substring(version.length + 4) ?? | ||||
|           Uri.parse(standardUrl).pathSegments.reversed.last; | ||||
|       String appName = | ||||
|           html.querySelector('h1.info-title')?.text.trim() ?? appId; | ||||
|       return APKDetails(version, apkUrls, AppNames(author, appName), | ||||
|           releaseDate: releaseDate); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -9,6 +9,7 @@ import 'package:obtainium/providers/source_provider.dart'; | ||||
| class Codeberg extends AppSource { | ||||
|   Codeberg() { | ||||
|     host = 'codeberg.org'; | ||||
|     overrideEligible = true; | ||||
|  | ||||
|     additionalSourceSpecificSettingFormItems = []; | ||||
|  | ||||
|   | ||||
| @@ -66,14 +66,15 @@ class FDroid extends AppSource { | ||||
|     String? appId = tryInferringAppId(standardUrl); | ||||
|     String host = Uri.parse(standardUrl).host; | ||||
|     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await get(Uri.parse('https://$host/api/v1/packages/$appId')), | ||||
|         await sourceRequest('https://$host/api/v1/packages/$appId'), | ||||
|         'https://$host/repo/$appId', | ||||
|         standardUrl); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<Map<String, List<String>>> search(String query) async { | ||||
|     Response res = await get(Uri.parse('https://search.$host/?q=$query')); | ||||
|     Response res = await sourceRequest( | ||||
|         'https://search.$host/?q=${Uri.encodeQueryComponent(query)}'); | ||||
|     if (res.statusCode == 200) { | ||||
|       Map<String, List<String>> urlsWithDescriptions = {}; | ||||
|       parse(res.body).querySelectorAll('.package-header').forEach((e) { | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import 'package:obtainium/providers/source_provider.dart'; | ||||
| class FDroidRepo extends AppSource { | ||||
|   FDroidRepo() { | ||||
|     name = tr('fdroidThirdPartyRepo'); | ||||
|     overrideEligible = true; | ||||
|  | ||||
|     additionalSourceAppSpecificSettingFormItems = [ | ||||
|       [ | ||||
| @@ -28,7 +29,7 @@ class FDroidRepo extends AppSource { | ||||
|     if (appIdOrName == null) { | ||||
|       throw NoReleasesError(); | ||||
|     } | ||||
|     var res = await get(Uri.parse('$standardUrl/index.xml')); | ||||
|     var res = await sourceRequest('$standardUrl/index.xml'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var body = parse(res.body); | ||||
|       var foundApps = body.querySelectorAll('application').where((element) { | ||||
|   | ||||
| @@ -2,8 +2,10 @@ import 'dart:convert'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/html.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| @@ -11,6 +13,7 @@ import 'package:url_launcher/url_launcher_string.dart'; | ||||
| class GitHub extends AppSource { | ||||
|   GitHub() { | ||||
|     host = 'github.com'; | ||||
|     overrideEligible = true; | ||||
|  | ||||
|     additionalSourceSpecificSettingFormItems = [ | ||||
|       GeneratedFormTextField('github-creds', | ||||
| @@ -34,7 +37,7 @@ class GitHub extends AppSource { | ||||
|           hint: tr('githubPATFormat'), | ||||
|           belowWidgets: [ | ||||
|             const SizedBox( | ||||
|               height: 8, | ||||
|               height: 4, | ||||
|             ), | ||||
|             GestureDetector( | ||||
|                 onTap: () { | ||||
| @@ -43,10 +46,13 @@ class GitHub extends AppSource { | ||||
|                       mode: LaunchMode.externalApplication); | ||||
|                 }, | ||||
|                 child: Text( | ||||
|                   tr('githubPATLinkText'), | ||||
|                   tr('about'), | ||||
|                   style: const TextStyle( | ||||
|                       decoration: TextDecoration.underline, fontSize: 12), | ||||
|                 )) | ||||
|                 )), | ||||
|             const SizedBox( | ||||
|               height: 4, | ||||
|             ), | ||||
|           ]) | ||||
|     ]; | ||||
|  | ||||
| @@ -108,7 +114,7 @@ class GitHub extends AppSource { | ||||
|                 true | ||||
|             ? additionalSettings['filterReleaseTitlesByRegEx'] | ||||
|             : null; | ||||
|     Response res = await get(Uri.parse(requestUrl)); | ||||
|     Response res = await sourceRequest(requestUrl); | ||||
|     if (res.statusCode == 200) { | ||||
|       var releases = jsonDecode(res.body) as List<dynamic>; | ||||
|  | ||||
| @@ -129,17 +135,28 @@ class GitHub extends AppSource { | ||||
|               ? DateTime.parse(rel['published_at']) | ||||
|               : null; | ||||
|       releases.sort((a, b) { | ||||
|         // See #478 | ||||
|         // See #478 and #534 | ||||
|         if (a == b) { | ||||
|           return 0; | ||||
|         } else if (a == null) { | ||||
|           return -1; | ||||
|         } else if (b == null) { | ||||
|           return 1; | ||||
|         } else { | ||||
|           var stdFormats = findStandardFormatsForVersion(a['tag_name'], true) | ||||
|               .intersection(findStandardFormatsForVersion(b['tag_name'], true)); | ||||
|           if (stdFormats.isNotEmpty) { | ||||
|             var reg = RegExp(stdFormats.first); | ||||
|             var matchA = reg.firstMatch(a['tag_name']); | ||||
|             var matchB = reg.firstMatch(b['tag_name']); | ||||
|             return compareAlphaNumeric( | ||||
|                 (a['tag_name'] as String).substring(matchA!.start, matchA.end), | ||||
|                 (b['tag_name'] as String).substring(matchB!.start, matchB.end)); | ||||
|           } else { | ||||
|             return getReleaseDateFromRelease(a)! | ||||
|                 .compareTo(getReleaseDateFromRelease(b)!); | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|       releases = releases.reversed.toList(); | ||||
|       dynamic targetRelease; | ||||
| @@ -216,7 +233,7 @@ class GitHub extends AppSource { | ||||
|   Future<Map<String, List<String>>> searchCommon( | ||||
|       String query, String requestUrl, String rootProp, | ||||
|       {Function(Response)? onHttpErrorCode}) async { | ||||
|     Response res = await get(Uri.parse(requestUrl)); | ||||
|     Response res = await sourceRequest(requestUrl); | ||||
|     if (res.statusCode == 200) { | ||||
|       Map<String, List<String>> urlsWithDescriptions = {}; | ||||
|       for (var e in (jsonDecode(res.body)[rootProp] as List<dynamic>)) { | ||||
|   | ||||
| @@ -1,14 +1,47 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class GitLab extends AppSource { | ||||
|   GitLab() { | ||||
|     host = 'gitlab.com'; | ||||
|     overrideEligible = true; | ||||
|     canSearch = true; | ||||
|  | ||||
|     additionalSourceSpecificSettingFormItems = [ | ||||
|       GeneratedFormTextField('gitlab-creds', | ||||
|           label: tr('gitlabPATLabel'), | ||||
|           password: true, | ||||
|           required: false, | ||||
|           belowWidgets: [ | ||||
|             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), | ||||
|                 )), | ||||
|             const SizedBox( | ||||
|               height: 4, | ||||
|             ) | ||||
|           ]) | ||||
|     ]; | ||||
|  | ||||
|     additionalSourceAppSpecificSettingFormItems = [ | ||||
|       [ | ||||
| @@ -28,6 +61,37 @@ class GitLab extends AppSource { | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   Future<String?> getPATIfAny() async { | ||||
|     SettingsProvider settingsProvider = SettingsProvider(); | ||||
|     await settingsProvider.initializeSettings(); | ||||
|     String? creds = settingsProvider | ||||
|         .getSettingString(additionalSourceSpecificSettingFormItems[0].key); | ||||
|     return creds != null && creds.isNotEmpty ? creds : null; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<Map<String, List<String>>> search(String query) async { | ||||
|     String? PAT = await getPATIfAny(); | ||||
|     if (PAT == null) { | ||||
|       throw CredsNeededError(name); | ||||
|     } | ||||
|     var url = | ||||
|         'https://$host/api/v4/search?private_token=$PAT&scope=projects&search=${Uri.encodeQueryComponent(query)}'; | ||||
|     var res = await sourceRequest(url); | ||||
|     if (res.statusCode != 200) { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|     var json = jsonDecode(res.body) as List<dynamic>; | ||||
|     Map<String, List<String>> results = {}; | ||||
|     json.forEach((element) { | ||||
|       results['https://$host/${element['path_with_namespace']}'] = [ | ||||
|         element['name_with_namespace'], | ||||
|         element['description'] ?? tr('noDescription') | ||||
|       ]; | ||||
|     }); | ||||
|     return results; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => | ||||
|       '$standardUrl/-/releases'; | ||||
| @@ -39,7 +103,7 @@ class GitLab extends AppSource { | ||||
|   ) async { | ||||
|     bool fallbackToOlderReleases = | ||||
|         additionalSettings['fallbackToOlderReleases'] == true; | ||||
|     Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); | ||||
|     Response res = await sourceRequest('$standardUrl/-/tags?format=atom'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var standardUri = Uri.parse(standardUrl); | ||||
|       var parsedHtml = parse(res.body); | ||||
|   | ||||
| @@ -4,10 +4,24 @@ import 'package:http/http.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class HTML extends AppSource { | ||||
|   @override | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     return url; | ||||
| String ensureAbsoluteUrl(String ambiguousUrl, Uri referenceAbsoluteUrl) { | ||||
|   try { | ||||
|     Uri.parse(ambiguousUrl).origin; | ||||
|     return ambiguousUrl; | ||||
|   } catch (err) { | ||||
|     // is relative | ||||
|   } | ||||
|   var currPathSegments = referenceAbsoluteUrl.path | ||||
|       .split('/') | ||||
|       .where((element) => element.trim().isNotEmpty) | ||||
|       .toList(); | ||||
|   if (ambiguousUrl.startsWith('/') || currPathSegments.isEmpty) { | ||||
|     return '${referenceAbsoluteUrl.origin}/$ambiguousUrl'; | ||||
|   } else if (ambiguousUrl.split('/').length == 1) { | ||||
|     return '${referenceAbsoluteUrl.origin}/${currPathSegments.join('/')}/$ambiguousUrl'; | ||||
|   } else { | ||||
|     return '${referenceAbsoluteUrl.origin}/${currPathSegments.sublist(0, currPathSegments.length - 1).join('/')}/$ambiguousUrl'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| int compareAlphaNumeric(String a, String b) { | ||||
| @@ -70,18 +84,29 @@ class HTML extends AppSource { | ||||
|   return s.codeUnitAt(0) >= 48 && s.codeUnitAt(0) <= 57; | ||||
| } | ||||
|  | ||||
| class HTML extends AppSource { | ||||
|   HTML() { | ||||
|     overrideEligible = true; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     return url; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     var uri = Uri.parse(standardUrl); | ||||
|     Response res = await get(uri); | ||||
|     Response res = await sourceRequest(standardUrl); | ||||
|     if (res.statusCode == 200) { | ||||
|       List<String> links = parse(res.body) | ||||
|           .querySelectorAll('a') | ||||
|           .map((element) => element.attributes['href'] ?? '') | ||||
|           .where((element) => element.toLowerCase().endsWith('.apk')) | ||||
|           .where((element) => | ||||
|               Uri.parse(element).path.toLowerCase().endsWith('.apk')) | ||||
|           .toList(); | ||||
|       links.sort( | ||||
|           (a, b) => compareAlphaNumeric(a.split('/').last, b.split('/').last)); | ||||
| @@ -95,25 +120,8 @@ class HTML extends AppSource { | ||||
|       var rel = links.last; | ||||
|       var apkName = rel.split('/').last; | ||||
|       var version = apkName.substring(0, apkName.length - 4); | ||||
|       List<String> apkUrls = [rel].map((e) { | ||||
|         try { | ||||
|           Uri.parse(e).origin; | ||||
|           return e; | ||||
|         } catch (err) { | ||||
|           // is relative | ||||
|         } | ||||
|         var currPathSegments = uri.path | ||||
|             .split('/') | ||||
|             .where((element) => element.trim().isNotEmpty) | ||||
|             .toList(); | ||||
|         if (e.startsWith('/') || currPathSegments.isEmpty) { | ||||
|           return '${uri.origin}/$e'; | ||||
|         } else if (e.split('/').length == 1) { | ||||
|           return '${uri.origin}/${currPathSegments.join('/')}/$e'; | ||||
|         } else { | ||||
|           return '${uri.origin}/${currPathSegments.sublist(0, currPathSegments.length - 1).join('/')}/$e'; | ||||
|         } | ||||
|       }).toList(); | ||||
|       List<String> apkUrls = | ||||
|           [rel].map((e) => ensureAbsoluteUrl(e, uri)).toList(); | ||||
|       return APKDetails( | ||||
|           version, getApkUrlsFromUrls(apkUrls), AppNames(uri.host, tr('app'))); | ||||
|     } else { | ||||
|   | ||||
| @@ -31,8 +31,8 @@ class IzzyOnDroid extends AppSource { | ||||
|   ) async { | ||||
|     String? appId = tryInferringAppId(standardUrl); | ||||
|     return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await get( | ||||
|             Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')), | ||||
|         await sourceRequest( | ||||
|             'https://apt.izzysoft.de/fdroid/api/v1/packages/$appId'), | ||||
|         'https://android.izzysoft.de/frepo/$appId', | ||||
|         standardUrl); | ||||
|   } | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class Jenkins extends AppSource { | ||||
|   Jenkins() { | ||||
|     overrideEligible = true; | ||||
|     overrideVersionDetectionFormDefault('releaseDateAsVersion', true); | ||||
|   } | ||||
|  | ||||
| @@ -30,7 +31,7 @@ class Jenkins extends AppSource { | ||||
|   ) async { | ||||
|     standardUrl = trimJobUrl(standardUrl); | ||||
|     Response res = | ||||
|         await get(Uri.parse('$standardUrl/lastSuccessfulBuild/api/json')); | ||||
|         await sourceRequest('$standardUrl/lastSuccessfulBuild/api/json'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var json = jsonDecode(res.body); | ||||
|       var releaseDate = json['timestamp'] == null | ||||
| @@ -55,9 +56,6 @@ class Jenkins extends AppSource { | ||||
|           .where((url) => | ||||
|               url.value.isNotEmpty && url.key.toLowerCase().endsWith('.apk')) | ||||
|           .toList(); | ||||
|       if (apkUrls.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       return APKDetails( | ||||
|           version, | ||||
|           apkUrls, | ||||
|   | ||||
| @@ -28,7 +28,7 @@ class Mullvad extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/en/download/android')); | ||||
|     Response res = await sourceRequest('$standardUrl/en/download/android'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var versions = parse(res.body) | ||||
|           .querySelectorAll('p') | ||||
|   | ||||
| @@ -78,7 +78,7 @@ class NeutronCode extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse(standardUrl)); | ||||
|     Response res = await sourceRequest(standardUrl); | ||||
|     if (res.statusCode == 200) { | ||||
|       var http = parse(res.body); | ||||
|       var name = http.querySelector('.pd-title')?.innerHtml; | ||||
|   | ||||
| @@ -19,7 +19,7 @@ class Signal extends AppSource { | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = | ||||
|         await get(Uri.parse('https://updates.$host/android/latest.json')); | ||||
|         await sourceRequest('https://updates.$host/android/latest.json'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var json = jsonDecode(res.body); | ||||
|       String? apkUrl = json['url']; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:obtainium/providers/source_provider.dart'; | ||||
| class SourceForge extends AppSource { | ||||
|   SourceForge() { | ||||
|     host = 'sourceforge.net'; | ||||
|     overrideEligible = true; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -29,7 +30,7 @@ class SourceForge extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/rss?path=/')); | ||||
|     Response res = await sourceRequest('$standardUrl/rss?path=/'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var parsedHtml = parse(res.body); | ||||
|       var allDownloadLinks = | ||||
|   | ||||
							
								
								
									
										101
									
								
								lib/app_sources/sourcehut.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								lib/app_sources/sourcehut.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/app_sources/html.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
|  | ||||
| class SourceHut extends AppSource { | ||||
|   SourceHut() { | ||||
|     host = 'git.sr.ht'; | ||||
|     overrideEligible = true; | ||||
|  | ||||
|     additionalSourceAppSpecificSettingFormItems = [ | ||||
|       [ | ||||
|         GeneratedFormSwitch('fallbackToOlderReleases', | ||||
|             label: tr('fallbackToOlderReleases'), defaultValue: true) | ||||
|       ] | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw InvalidURLError(name); | ||||
|     } | ||||
|     return url.substring(0, match.end); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl; | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Uri standardUri = Uri.parse(standardUrl); | ||||
|     String appName = standardUri.pathSegments.last; | ||||
|     bool fallbackToOlderReleases = | ||||
|         additionalSettings['fallbackToOlderReleases'] == true; | ||||
|     Response res = await sourceRequest('$standardUrl/refs/rss.xml'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var parsedHtml = parse(res.body); | ||||
|       List<APKDetails> apkDetailsList = []; | ||||
|       int ind = 0; | ||||
|  | ||||
|       for (var entry in parsedHtml.querySelectorAll('item').sublist(0, 6)) { | ||||
|         // Limit 5 for speed | ||||
|         if (!fallbackToOlderReleases && ind > 0) { | ||||
|           break; | ||||
|         } | ||||
|         String? version = entry.querySelector('title')?.text.trim(); | ||||
|         if (version == null) { | ||||
|           throw NoVersionError(); | ||||
|         } | ||||
|         String? releaseDateString = entry.querySelector('pubDate')?.innerHtml; | ||||
|         var link = entry.querySelector('link'); | ||||
|         String releasePage = '$standardUrl/refs/$version'; | ||||
|         DateTime? releaseDate = releaseDateString != null | ||||
|             ? DateFormat('EEE, dd MMM yyyy HH:mm:ss Z').parse(releaseDateString) | ||||
|             : null; | ||||
|         var res2 = await sourceRequest(releasePage); | ||||
|         List<MapEntry<String, String>> apkUrls = []; | ||||
|         if (res2.statusCode == 200) { | ||||
|           apkUrls = getApkUrlsFromUrls(parse(res2.body) | ||||
|               .querySelectorAll('a') | ||||
|               .map((e) => e.attributes['href'] ?? '') | ||||
|               .where((e) => e.toLowerCase().endsWith('.apk')) | ||||
|               .map((e) => ensureAbsoluteUrl(e, standardUri)) | ||||
|               .toList()); | ||||
|         } | ||||
|         apkDetailsList.add(APKDetails( | ||||
|             version, | ||||
|             apkUrls, | ||||
|             AppNames(entry.querySelector('author')?.innerHtml.trim() ?? appName, | ||||
|                 appName), | ||||
|             releaseDate: releaseDate)); | ||||
|         ind++; | ||||
|       } | ||||
|       if (apkDetailsList.isEmpty) { | ||||
|         throw NoReleasesError(); | ||||
|       } | ||||
|       if (fallbackToOlderReleases) { | ||||
|         if (additionalSettings['trackOnly'] != true) { | ||||
|           apkDetailsList = | ||||
|               apkDetailsList.where((e) => e.apkUrls.isNotEmpty).toList(); | ||||
|         } | ||||
|         if (apkDetailsList.isEmpty) { | ||||
|           throw NoReleasesError(); | ||||
|         } | ||||
|       } | ||||
|       return apkDetailsList.first; | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -29,7 +29,7 @@ class SteamMobile extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('https://$host/mobile')); | ||||
|     Response res = await sourceRequest('https://$host/mobile'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var apkNamePrefix = additionalSettings['app'] as String?; | ||||
|       if (apkNamePrefix == null) { | ||||
|   | ||||
| @@ -20,7 +20,7 @@ class TelegramApp extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('https://t.me/s/TAndroidAPK')); | ||||
|     Response res = await sourceRequest('https://t.me/s/TAndroidAPK'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var http = parse(res.body); | ||||
|       var messages = | ||||
|   | ||||
| @@ -19,8 +19,8 @@ class VLC extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get( | ||||
|         Uri.parse('https://www.videolan.org/vlc/download-android.html')); | ||||
|     Response res = await sourceRequest( | ||||
|         'https://www.videolan.org/vlc/download-android.html'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var dwUrlBase = 'get.videolan.org/vlc-android'; | ||||
|       var dwLinks = parse(res.body) | ||||
| @@ -38,7 +38,7 @@ class VLC extends AppSource { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       String? targetUrl = 'https://$dwUrlBase/$version/'; | ||||
|       Response res2 = await get(Uri.parse(targetUrl)); | ||||
|       Response res2 = await sourceRequest(targetUrl); | ||||
|       String mirrorDwBase = | ||||
|           'https://plug-mirror.rcac.purdue.edu/vlc/vlc-android/$version/'; | ||||
|       List<String> apkUrls = []; | ||||
|   | ||||
| @@ -14,21 +14,21 @@ class WhatsApp extends AppSource { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async { | ||||
|     Response res = await get(Uri.parse('https://www.whatsapp.com/android')); | ||||
|   Future<String> apkUrlPrefetchModifier( | ||||
|       String apkUrl, String standardUrl) async { | ||||
|     Response res = await sourceRequest('https://www.whatsapp.com/android'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var targetLinks = parse(res.body) | ||||
|           .querySelectorAll('a') | ||||
|           .map((e) => e.attributes['href']) | ||||
|           .where((e) => e != null) | ||||
|           .map((e) => e.attributes['href'] ?? '') | ||||
|           .where((e) => e.isNotEmpty) | ||||
|           .where((e) => | ||||
|               e!.contains('scontent.whatsapp.net') && | ||||
|               e.contains('WhatsApp.apk')) | ||||
|               e.contains('content.whatsapp.net') && e.contains('WhatsApp.apk')) | ||||
|           .toList(); | ||||
|       if (targetLinks.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       return targetLinks[0]!; | ||||
|       return targetLinks[0]; | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
| @@ -39,7 +39,7 @@ class WhatsApp extends AppSource { | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('https://www.whatsapp.com/android')); | ||||
|     Response res = await sourceRequest('https://www.whatsapp.com/android'); | ||||
|     if (res.statusCode == 200) { | ||||
|       var targetElements = parse(res.body) | ||||
|           .querySelectorAll('p') | ||||
|   | ||||
| @@ -25,6 +25,11 @@ class InvalidURLError extends ObtainiumError { | ||||
|       : super(tr('invalidURLForSource', args: [sourceName])); | ||||
| } | ||||
|  | ||||
| class CredsNeededError extends ObtainiumError { | ||||
|   CredsNeededError(String sourceName) | ||||
|       : super(tr('requiresCredentialsInSettings', args: [sourceName])); | ||||
| } | ||||
|  | ||||
| class NoReleasesError extends ObtainiumError { | ||||
|   NoReleasesError() : super(tr('noReleaseFound')); | ||||
| } | ||||
|   | ||||
| @@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart'; | ||||
| // ignore: implementation_imports | ||||
| import 'package:easy_localization/src/localization.dart'; | ||||
|  | ||||
| const String currentVersion = '0.12.3'; | ||||
| const String currentVersion = '0.13.0'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
|   | ||||
| @@ -248,9 +248,18 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|         searching = true; | ||||
|       }); | ||||
|       try { | ||||
|         var results = await Future.wait(sourceProvider.sources | ||||
|             .where((e) => e.canSearch) | ||||
|             .map((e) => e.search(searchQuery))); | ||||
|         var results = await Future.wait( | ||||
|             sourceProvider.sources.where((e) => e.canSearch).map((e) async { | ||||
|           try { | ||||
|             return await e.search(searchQuery); | ||||
|           } catch (err) { | ||||
|             if (err is! CredsNeededError) { | ||||
|               rethrow; | ||||
|             } else { | ||||
|               return <String, List<String>>{}; | ||||
|             } | ||||
|           } | ||||
|         })); | ||||
|  | ||||
|         // .then((results) async { | ||||
|         // Interleave results instead of simple reduce | ||||
| @@ -302,8 +311,10 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                         'overrideSource', | ||||
|                         defaultValue: HTML().runtimeType.toString(), | ||||
|                         [ | ||||
|                           ...sourceProvider.sources.map( | ||||
|                               (s) => MapEntry(s.runtimeType.toString(), s.name)) | ||||
|                           ...sourceProvider.sources | ||||
|                               .where((s) => s.overrideEligible) | ||||
|                               .map((s) => | ||||
|                                   MapEntry(s.runtimeType.toString(), s.name)) | ||||
|                         ], | ||||
|                         label: tr('overrideSource')) | ||||
|                   ] | ||||
|   | ||||
| @@ -185,6 +185,18 @@ class AppsPageState extends State<AppsPage> { | ||||
|       listedApps = [...temp, ...listedApps]; | ||||
|     } | ||||
|  | ||||
|     if (settingsProvider.buryNonInstalled) { | ||||
|       var temp = []; | ||||
|       listedApps = listedApps.where((sa) { | ||||
|         if (sa.app.installedVersion == null) { | ||||
|           temp.add(sa); | ||||
|           return false; | ||||
|         } | ||||
|         return true; | ||||
|       }).toList(); | ||||
|       listedApps = [...listedApps, ...temp]; | ||||
|     } | ||||
|  | ||||
|     var tempPinned = []; | ||||
|     var tempNotPinned = []; | ||||
|     for (var a in listedApps) { | ||||
|   | ||||
| @@ -205,6 +205,10 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|       height: 16, | ||||
|     ); | ||||
|  | ||||
|     const height32 = SizedBox( | ||||
|       height: 32, | ||||
|     ); | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -217,9 +221,26 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                       : Column( | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               tr('updates'), | ||||
|                               style: TextStyle( | ||||
|                                   fontWeight: FontWeight.bold, | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             intervalDropdown, | ||||
|                             height32, | ||||
|                             Text( | ||||
|                               tr('sourceSpecific'), | ||||
|                               style: TextStyle( | ||||
|                                   fontWeight: FontWeight.bold, | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             ...sourceSpecificFields, | ||||
|                             height32, | ||||
|                             Text( | ||||
|                               tr('appearance'), | ||||
|                               style: TextStyle( | ||||
|                                   fontWeight: FontWeight.bold, | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             themeDropdown, | ||||
| @@ -227,7 +248,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('useBlackTheme')), | ||||
|                                 Flexible(child: Text(tr('useBlackTheme'))), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.useBlackTheme, | ||||
|                                     onChanged: (value) { | ||||
| @@ -254,7 +275,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('showWebInAppView')), | ||||
|                                 Flexible(child: Text(tr('showWebInAppView'))), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.showAppWebpage, | ||||
|                                     onChanged: (value) { | ||||
| @@ -266,7 +287,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('pinUpdates')), | ||||
|                                 Flexible(child: Text(tr('pinUpdates'))), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.pinUpdates, | ||||
|                                     onChanged: (value) { | ||||
| @@ -278,7 +299,21 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('groupByCategory')), | ||||
|                                 Flexible( | ||||
|                                     child: Text( | ||||
|                                         tr('moveNonInstalledAppsToBottom'))), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.buryNonInstalled, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.buryNonInstalled = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Flexible(child: Text(tr('groupByCategory'))), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.groupByCategory, | ||||
|                                     onChanged: (value) { | ||||
| @@ -290,7 +325,9 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('dontShowTrackOnlyWarnings')), | ||||
|                                 Flexible( | ||||
|                                     child: | ||||
|                                         Text(tr('dontShowTrackOnlyWarnings'))), | ||||
|                                 Switch( | ||||
|                                     value: | ||||
|                                         settingsProvider.hideTrackOnlyWarning, | ||||
| @@ -304,7 +341,9 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('dontShowAPKOriginWarnings')), | ||||
|                                 Flexible( | ||||
|                                     child: | ||||
|                                         Text(tr('dontShowAPKOriginWarnings'))), | ||||
|                                 Switch( | ||||
|                                     value: | ||||
|                                         settingsProvider.hideAPKOriginWarning, | ||||
| @@ -314,31 +353,11 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 16, | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Text( | ||||
|                               tr('updates'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             intervalDropdown, | ||||
|                             const Divider( | ||||
|                               height: 48, | ||||
|                             ), | ||||
|                             Text( | ||||
|                               tr('sourceSpecific'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             ...sourceSpecificFields, | ||||
|                             const Divider( | ||||
|                               height: 48, | ||||
|                             ), | ||||
|                             height32, | ||||
|                             Text( | ||||
|                               tr('categories'), | ||||
|                               style: TextStyle( | ||||
|                                   fontWeight: FontWeight.bold, | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
|                             height16, | ||||
|   | ||||
| @@ -125,10 +125,13 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   downloadFile(String url, String fileName, Function? onProgress, | ||||
|       {bool useExisting = true}) async { | ||||
|       {bool useExisting = true, Map<String, String>? headers}) async { | ||||
|     var destDir = (await getExternalCacheDirectories())!.first.path; | ||||
|     StreamedResponse response = | ||||
|         await Client().send(Request('GET', Uri.parse(url))); | ||||
|     var req = Request('GET', Uri.parse(url)); | ||||
|     if (headers != null) { | ||||
|       req.headers.addAll(headers); | ||||
|     } | ||||
|     StreamedResponse response = await Client().send(req); | ||||
|     File downloadedFile = File('$destDir/$fileName'); | ||||
|     if (!(downloadedFile.existsSync() && useExisting)) { | ||||
|       File tempDownloadedFile = File('${downloadedFile.path}.part'); | ||||
| @@ -170,15 +173,16 @@ class AppsProvider with ChangeNotifier { | ||||
|       notifyListeners(); | ||||
|     } | ||||
|     try { | ||||
|       String downloadUrl = await SourceProvider() | ||||
|           .getSource(app.url, overrideSource: app.overrideSource) | ||||
|           .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex].value); | ||||
|       AppSource source = SourceProvider() | ||||
|           .getSource(app.url, overrideSource: app.overrideSource); | ||||
|       String downloadUrl = await source.apkUrlPrefetchModifier( | ||||
|           app.apkUrls[app.preferredApkIndex].value, app.url); | ||||
|       var fileName = '${app.id}-${downloadUrl.hashCode}.apk'; | ||||
|       var notif = DownloadNotification(app.finalName, 100); | ||||
|       notificationsProvider?.cancel(notif.id); | ||||
|       int? prevProg; | ||||
|       File downloadedFile = | ||||
|           await downloadFile(downloadUrl, fileName, (double? progress) { | ||||
|       File downloadedFile = await downloadFile(downloadUrl, fileName, | ||||
|           headers: source.requestHeaders, (double? progress) { | ||||
|         int? prog = progress?.ceil(); | ||||
|         if (apps[app.id] != null) { | ||||
|           apps[app.id]!.downloadProgress = progress; | ||||
|   | ||||
| @@ -154,6 +154,15 @@ class SettingsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get buryNonInstalled { | ||||
|     return prefs?.getBool('buryNonInstalled') ?? false; | ||||
|   } | ||||
|  | ||||
|   set buryNonInstalled(bool show) { | ||||
|     prefs?.setBool('buryNonInstalled', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get groupByCategory { | ||||
|     return prefs?.getBool('groupByCategory') ?? false; | ||||
|   } | ||||
|   | ||||
| @@ -7,7 +7,9 @@ import 'package:device_info_plus/device_info_plus.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:html/dom.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/apkcombo.dart'; | ||||
| import 'package:obtainium/app_sources/apkmirror.dart'; | ||||
| import 'package:obtainium/app_sources/apkpure.dart'; | ||||
| import 'package:obtainium/app_sources/codeberg.dart'; | ||||
| import 'package:obtainium/app_sources/fdroid.dart'; | ||||
| import 'package:obtainium/app_sources/fdroidrepo.dart'; | ||||
| @@ -20,6 +22,7 @@ import 'package:obtainium/app_sources/mullvad.dart'; | ||||
| import 'package:obtainium/app_sources/neutroncode.dart'; | ||||
| import 'package:obtainium/app_sources/signal.dart'; | ||||
| import 'package:obtainium/app_sources/sourceforge.dart'; | ||||
| import 'package:obtainium/app_sources/sourcehut.dart'; | ||||
| import 'package:obtainium/app_sources/steammobile.dart'; | ||||
| import 'package:obtainium/app_sources/telegramapp.dart'; | ||||
| import 'package:obtainium/app_sources/vlc.dart'; | ||||
| @@ -315,6 +318,7 @@ abstract class AppSource { | ||||
|   late String name; | ||||
|   bool enforceTrackOnly = false; | ||||
|   bool changeLogIfAnyIsMarkDown = true; | ||||
|   bool overrideEligible = false; | ||||
|  | ||||
|   AppSource() { | ||||
|     name = runtimeType.toString(); | ||||
| @@ -344,6 +348,18 @@ abstract class AppSource { | ||||
|     return url; | ||||
|   } | ||||
|  | ||||
|   Map<String, String>? get requestHeaders => null; | ||||
|  | ||||
|   Future<Response> sourceRequest(String url) async { | ||||
|     if (requestHeaders != null) { | ||||
|       var req = Request('GET', Uri.parse(url)); | ||||
|       req.headers.addAll(requestHeaders!); | ||||
|       return Response.fromStream(await Client().send(req)); | ||||
|     } else { | ||||
|       return get(Uri.parse(url)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   String sourceSpecificStandardizeURL(String url) { | ||||
|     throw NotImplementedError(); | ||||
|   } | ||||
| @@ -410,7 +426,8 @@ abstract class AppSource { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async { | ||||
|   Future<String> apkUrlPrefetchModifier( | ||||
|       String apkUrl, String standardUrl) async { | ||||
|     return apkUrl; | ||||
|   } | ||||
|  | ||||
| @@ -459,7 +476,10 @@ class SourceProvider { | ||||
|         FDroidRepo(), | ||||
|         Jenkins(), | ||||
|         SourceForge(), | ||||
|         SourceHut(), | ||||
|         APKMirror(), | ||||
|         APKPure(), | ||||
|         // APKCombo(), // Can't get past their scraping blocking yet (get 403 Forbidden) | ||||
|         Mullvad(), | ||||
|         Signal(), | ||||
|         VLC(), | ||||
|   | ||||
							
								
								
									
										12
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -94,10 +94,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: crypto | ||||
|       sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 | ||||
|       sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.0.2" | ||||
|     version: "3.0.3" | ||||
|   csslib: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -256,10 +256,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_plugin_android_lifecycle | ||||
|       sha256: "8ffe990dac54a4a5492747added38571a5ab474c8e5d196809ea08849c69b1bb" | ||||
|       sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.13" | ||||
|     version: "2.0.14" | ||||
|   flutter_test: | ||||
|     dependency: "direct dev" | ||||
|     description: flutter | ||||
| @@ -623,10 +623,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: sqflite | ||||
|       sha256: "8453780d1f703ead201a39673deb93decf85d543f359f750e2afc4908b55533f" | ||||
|       sha256: acf091c6e55c50d00b30b8532b2dd23e393cf775861665ebd0f15cdd6ebfb079 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.2.8" | ||||
|     version: "2.2.8+1" | ||||
|   sqflite_common: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.12.3+163 # When changing this, update the tag in main() accordingly | ||||
| version: 0.13.0+164 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
|   | ||||
		Reference in New Issue
	
	Block a user