mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-26 11:13:46 +01:00 
			
		
		
		
	Compare commits
	
		
			43 Commits
		
	
	
		
			v0.11.11-b
			...
			v0.11.23-b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | e8cbac8657 | ||
|  | b66c13d319 | ||
|  | 782d055bc3 | ||
|  | d557746965 | ||
|  | e6b05d50b9 | ||
|  | dea635fa6a | ||
|  | 682026ed0a | ||
|  | 9fe8a200ef | ||
|  | 210100da2b | ||
|  | d52660235b | ||
|  | e386b5ab8a | ||
|  | abf7be222d | ||
|  | 4c5b9304c0 | ||
|  | 4cfe6af044 | ||
|  | 3f0c4068dd | ||
|  | 7981ca29c5 | ||
|  | 187efa8fc5 | ||
|  | cd27ff7f2d | ||
|  | 6f6a25511b | ||
|  | 4e17bbcfd1 | ||
|  | 814e269d1d | ||
|  | 6b7d962b87 | ||
|  | 9fba747802 | ||
|  | c7cd35b6a1 | ||
|  | a8a3fce33a | ||
|  | 3a38cedcf5 | ||
|  | 69ccefcf1a | ||
|  | d3932f317d | ||
|  | 895deeead5 | ||
|  | 4c04af3868 | ||
|  | 07c490bb0e | ||
|  | a081d553bb | ||
|  | 3bc5837999 | ||
|  | 9fbe524818 | ||
|  | c21a9d7292 | ||
|  | 9c6068b270 | ||
|  | cd86d6112b | ||
|  | 1112c79c14 | ||
|  | 08555bac75 | ||
|  | 6db31e2b24 | ||
|  | 48d2532323 | ||
|  | f1fc43a3e7 | ||
|  | 280827d8ec | 
| @@ -20,6 +20,7 @@ Currently supported App sources: | ||||
|   - Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo` | ||||
| - [Steam](https://store.steampowered.com/mobile) | ||||
| - [Telegram App](https://telegram.org) | ||||
| - [VLC](https://www.videolan.org/vlc/download-android.html) | ||||
| - [Neutron Code](https://neutroncode.com) | ||||
| - "HTML" (Fallback) | ||||
|   - Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked) | ||||
|   | ||||
							
								
								
									
										30
									
								
								assets/ca/lets-encrypt-r3.pem
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								assets/ca/lets-encrypt-r3.pem
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw | ||||
| TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh | ||||
| cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw | ||||
| WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg | ||||
| RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK | ||||
| AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP | ||||
| R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx | ||||
| sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm | ||||
| NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg | ||||
| Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG | ||||
| /kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC | ||||
| AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB | ||||
| Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA | ||||
| FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw | ||||
| AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw | ||||
| Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB | ||||
| gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W | ||||
| PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl | ||||
| ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz | ||||
| CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm | ||||
| lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4 | ||||
| avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2 | ||||
| yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O | ||||
| yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids | ||||
| hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+ | ||||
| HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv | ||||
| MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX | ||||
| nLRbwHOoq7hHwg== | ||||
| -----END CERTIFICATE----- | ||||
| @@ -220,6 +220,7 @@ | ||||
|     "importFromURLsInFile": "Importieren von URLs aus Datei ( z.B. OPML)", | ||||
|     "versionDetection": "Versionserkennung", | ||||
|     "standardVersionDetection": "Standardversionserkennung", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "App entfernen?", | ||||
|         "other": "App entfernen?" | ||||
|   | ||||
| @@ -220,6 +220,7 @@ | ||||
|     "importFromURLsInFile": "Import from URLs in File (like OPML)", | ||||
|     "versionDetection": "Version Detection", | ||||
|     "standardVersionDetection": "Standard version detection", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Remove App?", | ||||
|         "other": "Remove Apps?" | ||||
|   | ||||
| @@ -220,6 +220,7 @@ | ||||
|     "importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)", | ||||
|     "versionDetection": "تشخیص نسخه", | ||||
|     "standardVersionDetection": "تشخیص نسخه استاندارد", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "برنامه حذف شود؟", | ||||
|         "other": "برنامه ها حذف شوند؟" | ||||
|   | ||||
| @@ -220,6 +220,7 @@ | ||||
|     "importFromURLsInFile": "Importer à partir d'URL dans un fichier (comme OPML)", | ||||
|     "versionDetection": "Détection des versions", | ||||
|     "standardVersionDetection": "Détection de version standard", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Supprimer l'application ?", | ||||
|         "other": "Supprimer les applications ?" | ||||
|   | ||||
| @@ -219,6 +219,7 @@ | ||||
|     "importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)", | ||||
|     "versionDetection": "Verzió érzékelés", | ||||
|     "standardVersionDetection": "Alapért. verzió érzékelés", | ||||
|     "groupByCategory": "Csoportosítás Kategória alapján", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Eltávolítja az alkalmazást?", | ||||
|         "other": "Eltávolítja az alkalmazást?" | ||||
|   | ||||
| @@ -217,9 +217,10 @@ | ||||
|     "releaseDateAsVersionExplanation": "Questa opzione dovrebbe essere usata solo per le App in cui il rilevamento della versione non funziona correttamente, ma è disponibile una data di rilascio.", | ||||
|     "changes": "Novità", | ||||
|     "releaseDate": "Data di rilascio", | ||||
|     "importFromURLsInFile": "Import from URLs in File (like OPML)", | ||||
|     "versionDetection": "Version Detection", | ||||
|     "standardVersionDetection": "Standard version detection", | ||||
|     "importFromURLsInFile": "Importa da URL in file (come OPML)", | ||||
|     "versionDetection": "Rilevamento di versione", | ||||
|     "standardVersionDetection": "Rilevamento di versione standard", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "Rimuovere l'App?", | ||||
|         "other": "Rimuovere le App?" | ||||
|   | ||||
| @@ -220,6 +220,7 @@ | ||||
|     "importFromURLsInFile": "ファイル(OPMLなど)内のURLからインポート", | ||||
|     "versionDetection": "バージョン検出", | ||||
|     "standardVersionDetection": "標準のバージョン検出", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "アプリを削除しますか?", | ||||
|         "other": "アプリを削除しますか?" | ||||
|   | ||||
| @@ -220,6 +220,7 @@ | ||||
|     "importFromURLsInFile": "Import from URLs in File (like OPML)", | ||||
|     "versionDetection": "Version Detection", | ||||
|     "standardVersionDetection": "Standard version detection", | ||||
|     "groupByCategory": "Group by Category", | ||||
|     "removeAppQuestion": { | ||||
|         "one": "删除应用?", | ||||
|         "other": "删除应用?" | ||||
|   | ||||
| @@ -14,12 +14,14 @@ class FDroid extends AppSource { | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     RegExp standardUrlRegExB = | ||||
|         RegExp('^https?://$host/+[^/]+/+packages/+[^/]+'); | ||||
|         RegExp('^https?://(cloudflare\\.)?$host/+[^/]+/+packages/+[^/]+'); | ||||
|     RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase()); | ||||
|     if (match != null) { | ||||
|       url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}'; | ||||
|       url = | ||||
|           'https://${Uri.parse(url.substring(0, match.end)).host}/packages/${Uri.parse(url).pathSegments.last}'; | ||||
|     } | ||||
|     RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+'); | ||||
|     RegExp standardUrlRegExA = | ||||
|         RegExp('^https?://(cloudflare\\.)?$host/+packages/+[^/]+'); | ||||
|     match = standardUrlRegExA.firstMatch(url.toLowerCase()); | ||||
|     if (match == null) { | ||||
|       throw InvalidURLError(name); | ||||
| @@ -61,9 +63,10 @@ class FDroid extends AppSource { | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     String? appId = tryInferringAppId(standardUrl); | ||||
|     String host = Uri.parse(standardUrl).host; | ||||
|     return getAPKUrlsFromFDroidPackagesAPIResponse( | ||||
|         await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')), | ||||
|         'https://f-droid.org/repo/$appId', | ||||
|         await get(Uri.parse('https://$host/api/v1/packages/$appId')), | ||||
|         'https://$host/repo/$appId', | ||||
|         standardUrl); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -185,9 +185,11 @@ class GitHub extends AppSource { | ||||
|       Map<String, String> urlsWithDescriptions = {}; | ||||
|       for (var e in (jsonDecode(res.body)['items'] as List<dynamic>)) { | ||||
|         urlsWithDescriptions.addAll({ | ||||
|           e['html_url'] as String: e['description'] != null | ||||
|           e['html_url'] as String: | ||||
|               ((e['archived'] == true ? '[ARCHIVED] ' : '') + | ||||
|                   (e['description'] != null | ||||
|                       ? e['description'] as String | ||||
|               : tr('noDescription') | ||||
|                       : tr('noDescription'))) | ||||
|         }); | ||||
|       } | ||||
|       return urlsWithDescriptions; | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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/source_provider.dart'; | ||||
|  | ||||
| @@ -29,19 +30,37 @@ class Mullvad extends AppSource { | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('$standardUrl/en/download/android')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var version = parse(res.body) | ||||
|           .querySelector('p.subtitle.is-6') | ||||
|           ?.querySelector('a') | ||||
|           ?.attributes['href'] | ||||
|           ?.split('/') | ||||
|           .last; | ||||
|       if (version == null) { | ||||
|       var versions = parse(res.body) | ||||
|           .querySelectorAll('p') | ||||
|           .map((e) => e.innerHtml) | ||||
|           .where((p) => p.contains('Latest version: ')) | ||||
|           .map((e) { | ||||
|             var match = RegExp('[0-9]+(\\.[0-9]+)*').firstMatch(e); | ||||
|             if (match == null) { | ||||
|               return ''; | ||||
|             } else { | ||||
|               return e.substring(match.start, match.end); | ||||
|             } | ||||
|           }) | ||||
|           .where((element) => element.isNotEmpty) | ||||
|           .toList(); | ||||
|       if (versions.isEmpty) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       String? changeLog; | ||||
|       try { | ||||
|         changeLog = (await GitHub().getLatestAPKDetails( | ||||
|                 'https://github.com/mullvad/mullvadvpn-app', | ||||
|                 {'fallbackToOlderReleases': true})) | ||||
|             .changeLog; | ||||
|       } catch (e) { | ||||
|         // Ignore | ||||
|       } | ||||
|       return APKDetails( | ||||
|           version, | ||||
|           versions[0], | ||||
|           ['https://mullvad.net/download/app/apk/latest'], | ||||
|           AppNames(name, 'Mullvad-VPN')); | ||||
|           AppNames(name, 'Mullvad-VPN'), | ||||
|           changeLog: changeLog); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										62
									
								
								lib/app_sources/vlc.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								lib/app_sources/vlc.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/app_sources/html.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class VLC extends AppSource { | ||||
|   VLC() { | ||||
|     host = 'videolan.org'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     return 'https://$host'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get( | ||||
|         Uri.parse('https://www.videolan.org/vlc/download-android.html')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var dwUrlBase = 'get.videolan.org/vlc-android'; | ||||
|       var dwLinks = parse(res.body) | ||||
|           .querySelectorAll('a') | ||||
|           .where((element) => | ||||
|               element.attributes['href']?.contains(dwUrlBase) ?? false) | ||||
|           .toList(); | ||||
|       String? version = dwLinks.isNotEmpty | ||||
|           ? dwLinks.first.attributes['href'] | ||||
|               ?.split('/') | ||||
|               .where((s) => s.isNotEmpty) | ||||
|               .last | ||||
|           : null; | ||||
|       if (version == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       String? targetUrl = 'https://$dwUrlBase/$version/'; | ||||
|       Response res2 = await get(Uri.parse(targetUrl)); | ||||
|       String mirrorDwBase = | ||||
|           'https://plug-mirror.rcac.purdue.edu/vlc/vlc-android/$version/'; | ||||
|       List<String> apkUrls = []; | ||||
|       if (res2.statusCode == 200) { | ||||
|         apkUrls = parse(res2.body) | ||||
|             .querySelectorAll('a') | ||||
|             .map((e) => e.attributes['href']) | ||||
|             .where((h) => | ||||
|                 h != null && h.isNotEmpty && h.toLowerCase().endsWith('.apk')) | ||||
|             .map((e) => mirrorDwBase + e!) | ||||
|             .toList(); | ||||
|       } else { | ||||
|         throw getObtainiumHttpError(res2); | ||||
|       } | ||||
|  | ||||
|       return APKDetails(version, apkUrls, AppNames('VideoLAN', 'VLC')); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										75
									
								
								lib/app_sources/whatsapp.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								lib/app_sources/whatsapp.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| import 'package:html/parser.dart'; | ||||
| import 'package:http/http.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
|  | ||||
| class WhatsApp extends AppSource { | ||||
|   WhatsApp() { | ||||
|     host = 'whatsapp.com'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String standardizeURL(String url) { | ||||
|     return 'https://$host'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<String> apkUrlPrefetchModifier(String apkUrl) async { | ||||
|     Response res = await get(Uri.parse('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) | ||||
|           .where((e) => | ||||
|               e!.contains('scontent.whatsapp.net') && | ||||
|               e.contains('WhatsApp.apk')) | ||||
|           .toList(); | ||||
|       if (targetLinks.isEmpty) { | ||||
|         throw NoAPKError(); | ||||
|       } | ||||
|       return targetLinks[0]!; | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|     String standardUrl, | ||||
|     Map<String, dynamic> additionalSettings, | ||||
|   ) async { | ||||
|     Response res = await get(Uri.parse('https://www.whatsapp.com/android')); | ||||
|     if (res.statusCode == 200) { | ||||
|       var targetElements = parse(res.body) | ||||
|           .querySelectorAll('p') | ||||
|           .where((element) => element.innerHtml.contains('Version ')) | ||||
|           .toList(); | ||||
|       if (targetElements.isEmpty) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       var vLines = targetElements[0] | ||||
|           .innerHtml | ||||
|           .split('\n') | ||||
|           .where((element) => element.contains('Version ')) | ||||
|           .toList(); | ||||
|       if (vLines.isEmpty) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       var versionMatch = RegExp('[0-9]+(\\.[0-9]+)+').firstMatch(vLines[0]); | ||||
|       if (versionMatch == null) { | ||||
|         throw NoVersionError(); | ||||
|       } | ||||
|       String version = | ||||
|           vLines[0].substring(versionMatch.start, versionMatch.end); | ||||
|       return APKDetails( | ||||
|           version, | ||||
|           [ | ||||
|             'https://www.whatsapp.com/android?v=$version&=thisIsaPlaceholder&a=realURLPrefetchedAtDownloadTime' | ||||
|           ], | ||||
|           AppNames('Meta', 'WhatsApp')); | ||||
|     } else { | ||||
|       throw getObtainiumHttpError(res); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -48,6 +48,7 @@ class GeneratedFormTextField extends GeneratedFormItem { | ||||
|  | ||||
| class GeneratedFormDropdown extends GeneratedFormItem { | ||||
|   late List<MapEntry<String, String>>? opts; | ||||
|   List<String>? disabledOptKeys; | ||||
|  | ||||
|   GeneratedFormDropdown( | ||||
|     String key, | ||||
| @@ -55,6 +56,7 @@ class GeneratedFormDropdown extends GeneratedFormItem { | ||||
|     String label = 'Input', | ||||
|     List<Widget> belowWidgets = const [], | ||||
|     String defaultValue = '', | ||||
|     this.disabledOptKeys, | ||||
|     List<String? Function(String? value)> additionalValidators = const [], | ||||
|   }) : super(key, | ||||
|             label: label, | ||||
| @@ -225,10 +227,15 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|           return DropdownButtonFormField( | ||||
|               decoration: InputDecoration(labelText: formItem.label), | ||||
|               value: values[formItem.key], | ||||
|               items: formItem.opts! | ||||
|                   .map((e2) => | ||||
|                       DropdownMenuItem(value: e2.key, child: Text(e2.value))) | ||||
|                   .toList(), | ||||
|               items: formItem.opts!.map((e2) { | ||||
|                 var enabled = | ||||
|                     formItem.disabledOptKeys?.contains(e2.key) != true; | ||||
|                 return DropdownMenuItem( | ||||
|                     value: e2.key, | ||||
|                     enabled: enabled, | ||||
|                     child: Opacity( | ||||
|                         opacity: enabled ? 1 : 0.5, child: Text(e2.value))); | ||||
|               }).toList(), | ||||
|               onChanged: (value) { | ||||
|                 setState(() { | ||||
|                   values[formItem.key] = value ?? formItem.opts!.first.key; | ||||
| @@ -476,6 +483,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         rowItems.add(Expanded( | ||||
|             child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|               rowInput.value, | ||||
|               ...widget.items[rowInputs.key][rowInput.key].belowWidgets | ||||
|   | ||||
| @@ -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.11.11'; | ||||
| const String currentVersion = '0.11.23'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| @@ -147,6 +147,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   try { | ||||
|     ByteData data = | ||||
|         await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem'); | ||||
|     SecurityContext.defaultContext | ||||
|         .setTrustedCertificatesBytes(data.buffer.asUint8List()); | ||||
|   } catch (e) { | ||||
|     // Already added, do nothing (see #375) | ||||
|   } | ||||
|   await EasyLocalization.ensureInitialized(); | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { | ||||
|     SystemChrome.setSystemUIOverlayStyle( | ||||
| @@ -210,7 +218,7 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|               {'includePrereleases': true}, | ||||
|               null, | ||||
|               false) | ||||
|         ]); | ||||
|         ], onlyIfExists: false); | ||||
|       } | ||||
|       if (!supportedLocales | ||||
|               .map((e) => e.languageCode) | ||||
|   | ||||
| @@ -33,10 +33,10 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|   bool additionalSettingsValid = true; | ||||
|   List<String> pickedCategories = []; | ||||
|   int searchnum = 0; | ||||
|   SourceProvider sourceProvider = SourceProvider(); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
|  | ||||
|     bool doingSomething = gettingAppInfo || searching; | ||||
| @@ -64,15 +64,8 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     addApp({bool resetUserInputAfter = false}) async { | ||||
|       setState(() { | ||||
|         gettingAppInfo = true; | ||||
|       }); | ||||
|       var settingsProvider = context.read<SettingsProvider>(); | ||||
|       () async { | ||||
|         var userPickedTrackOnly = additionalSettings['trackOnly'] == true; | ||||
|         var cont = true; | ||||
|         if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && | ||||
|     getTrackOnlyConfirmationIfNeeded(bool userPickedTrackOnly) async { | ||||
|       return (!((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && | ||||
|           // ignore: use_build_context_synchronously | ||||
|           await showDialog( | ||||
|                   context: context, | ||||
| @@ -88,10 +81,13 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                           '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}', | ||||
|                     ); | ||||
|                   }) == | ||||
|                 null) { | ||||
|           cont = false; | ||||
|               null)); | ||||
|     } | ||||
|         if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' && | ||||
|  | ||||
|     getReleaseDateAsVersionConfirmationIfNeeded( | ||||
|         bool userPickedTrackOnly) async { | ||||
|       return (!(additionalSettings['versionDetection'] == | ||||
|               'releaseDateAsVersion' && | ||||
|           // ignore: use_build_context_synchronously | ||||
|           await showDialog( | ||||
|                   context: context, | ||||
| @@ -102,27 +98,22 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                       message: tr('releaseDateAsVersionExplanation'), | ||||
|                     ); | ||||
|                   }) == | ||||
|                 null) { | ||||
|           cont = false; | ||||
|               null)); | ||||
|     } | ||||
|         if (additionalSettings['versionDetection'] == 'noVersionDetection' && | ||||
|             // ignore: use_build_context_synchronously | ||||
|             await showDialog( | ||||
|                     context: context, | ||||
|                     builder: (BuildContext ctx) { | ||||
|                       return GeneratedFormModal( | ||||
|                         title: tr('disableVersionDetection'), | ||||
|                         items: const [], | ||||
|                         message: tr('noVersionDetectionExplanation'), | ||||
|                       ); | ||||
|                     }) == | ||||
|                 null) { | ||||
|           cont = false; | ||||
|         } | ||||
|         if (cont) { | ||||
|           HapticFeedback.selectionClick(); | ||||
|  | ||||
|     addApp({bool resetUserInputAfter = false}) async { | ||||
|       setState(() { | ||||
|         gettingAppInfo = true; | ||||
|       }); | ||||
|       try { | ||||
|         var settingsProvider = context.read<SettingsProvider>(); | ||||
|         var userPickedTrackOnly = additionalSettings['trackOnly'] == true; | ||||
|         App? app; | ||||
|         if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) && | ||||
|             (await getReleaseDateAsVersionConfirmationIfNeeded( | ||||
|                 userPickedTrackOnly))) { | ||||
|           var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; | ||||
|           App app = await sourceProvider.getApp( | ||||
|           app = await sourceProvider.getApp( | ||||
|               pickedSource!, userInput, additionalSettings, | ||||
|               trackOnlyOverride: trackOnly); | ||||
|           if (!trackOnly) { | ||||
| @@ -149,39 +140,25 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|             app.installedVersion = app.latestVersion; | ||||
|           } | ||||
|           app.categories = pickedCategories; | ||||
|           await appsProvider.saveApps([app]); | ||||
|  | ||||
|           return app; | ||||
|           await appsProvider.saveApps([app], onlyIfExists: false); | ||||
|         } | ||||
|       }() | ||||
|           .then((app) { | ||||
|         if (app != null) { | ||||
|           Navigator.push(context, | ||||
|               MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); | ||||
|           Navigator.push(globalNavigatorKey.currentContext ?? context, | ||||
|               MaterialPageRoute(builder: (context) => AppPage(appId: app!.id))); | ||||
|         } | ||||
|       }).catchError((e) { | ||||
|       } catch (e) { | ||||
|         showError(e, context); | ||||
|       }).whenComplete(() { | ||||
|       } finally { | ||||
|         setState(() { | ||||
|           gettingAppInfo = false; | ||||
|           if (resetUserInputAfter) { | ||||
|             changeUserInput('', false, true); | ||||
|           } | ||||
|         }); | ||||
|       }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           CustomAppBar(title: tr('addApp')), | ||||
|           SliverFillRemaining( | ||||
|             child: Padding( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
|                 child: Column( | ||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                     children: [ | ||||
|                       Row( | ||||
|     Widget getUrlInputRow() => Row( | ||||
|           children: [ | ||||
|             Expanded( | ||||
|                 child: GeneratedForm( | ||||
| @@ -197,8 +174,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                   sourceProvider | ||||
|                                       .getSource(value ?? '') | ||||
|                                       .standardizeURL( | ||||
|                                                         preStandardizeUrl( | ||||
|                                                             value ?? '')); | ||||
|                                           preStandardizeUrl(value ?? '')); | ||||
|                                 } catch (e) { | ||||
|                                   return e is String | ||||
|                                       ? e | ||||
| @@ -212,8 +188,8 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                       ] | ||||
|                     ], | ||||
|                     onValueChanges: (values, valid, isBuilding) { | ||||
|                                     changeUserInput(values['appSourceURL']!, | ||||
|                                         valid, isBuilding); | ||||
|                       changeUserInput( | ||||
|                           values['appSourceURL']!, valid, isBuilding); | ||||
|                     })), | ||||
|             const SizedBox( | ||||
|               width: 16, | ||||
| @@ -223,47 +199,85 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                 : ElevatedButton( | ||||
|                     onPressed: doingSomething || | ||||
|                             pickedSource == null || | ||||
|                                           (pickedSource! | ||||
|                                                   .combinedAppSpecificSettingFormItems | ||||
|                             (pickedSource!.combinedAppSpecificSettingFormItems | ||||
|                                     .isNotEmpty && | ||||
|                                 !additionalSettingsValid) | ||||
|                         ? null | ||||
|                                       : addApp, | ||||
|                         : () { | ||||
|                             HapticFeedback.selectionClick(); | ||||
|                             addApp(); | ||||
|                           }, | ||||
|                     child: Text(tr('add'))) | ||||
|           ], | ||||
|                       ), | ||||
|                       if (sourceProvider.sources | ||||
|         ); | ||||
|  | ||||
|     runSearch() async { | ||||
|       setState(() { | ||||
|         searching = true; | ||||
|       }); | ||||
|       try { | ||||
|         var results = await Future.wait(sourceProvider.sources | ||||
|             .where((e) => e.canSearch) | ||||
|                               .isNotEmpty && | ||||
|             .map((e) => e.search(searchQuery))); | ||||
|  | ||||
|         // .then((results) async { | ||||
|         // Interleave results instead of simple reduce | ||||
|         Map<String, String> res = {}; | ||||
|         var si = 0; | ||||
|         var done = false; | ||||
|         while (!done) { | ||||
|           done = true; | ||||
|           for (var r in results) { | ||||
|             if (r.length > si) { | ||||
|               done = false; | ||||
|               res.addEntries([r.entries.elementAt(si)]); | ||||
|             } | ||||
|           } | ||||
|           si++; | ||||
|         } | ||||
|         List<String>? selectedUrls = res.isEmpty | ||||
|             ? [] | ||||
|             // ignore: use_build_context_synchronously | ||||
|             : await showDialog<List<String>?>( | ||||
|                 context: context, | ||||
|                 builder: (BuildContext ctx) { | ||||
|                   return UrlSelectionModal( | ||||
|                     urlsWithDescriptions: res, | ||||
|                     selectedByDefault: false, | ||||
|                     onlyOneSelectionAllowed: true, | ||||
|                   ); | ||||
|                 }); | ||||
|         if (selectedUrls != null && selectedUrls.isNotEmpty) { | ||||
|           changeUserInput(selectedUrls[0], true, false, isSearch: true); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         showError(e, context); | ||||
|       } finally { | ||||
|         setState(() { | ||||
|           searching = false; | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     bool shouldShowSearchBar() => | ||||
|         sourceProvider.sources.where((e) => e.canSearch).isNotEmpty && | ||||
|         pickedSource == null && | ||||
|                           userInput.isEmpty) | ||||
|                         const SizedBox( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                       if (sourceProvider.sources | ||||
|                               .where((e) => e.canSearch) | ||||
|                               .isNotEmpty && | ||||
|                           pickedSource == null && | ||||
|                           userInput.isEmpty) | ||||
|                         Row( | ||||
|         userInput.isEmpty; | ||||
|  | ||||
|     Widget getSearchBarRow() => Row( | ||||
|           children: [ | ||||
|             Expanded( | ||||
|               child: GeneratedForm( | ||||
|                   items: [ | ||||
|                     [ | ||||
|                                       GeneratedFormTextField( | ||||
|                                           'searchSomeSources', | ||||
|                                           label: tr('searchSomeSourcesLabel'), | ||||
|                                           required: false), | ||||
|                       GeneratedFormTextField('searchSomeSources', | ||||
|                           label: tr('searchSomeSourcesLabel'), required: false), | ||||
|                     ] | ||||
|                   ], | ||||
|                   onValueChanges: (values, valid, isBuilding) { | ||||
|                                     if (values.isNotEmpty && | ||||
|                                         valid && | ||||
|                                         !isBuilding) { | ||||
|                     if (values.isNotEmpty && valid && !isBuilding) { | ||||
|                       setState(() { | ||||
|                                         searchQuery = | ||||
|                                             values['searchSomeSources']!.trim(); | ||||
|                         searchQuery = values['searchSomeSources']!.trim(); | ||||
|                       }); | ||||
|                     } | ||||
|                   }), | ||||
| @@ -275,61 +289,13 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                 onPressed: searchQuery.isEmpty || doingSomething | ||||
|                     ? null | ||||
|                     : () { | ||||
|                                         setState(() { | ||||
|                                           searching = true; | ||||
|                                         }); | ||||
|                                         Future.wait(sourceProvider.sources | ||||
|                                                 .where((e) => e.canSearch) | ||||
|                                                 .map((e) => | ||||
|                                                     e.search(searchQuery))) | ||||
|                                             .then((results) async { | ||||
|                                           // Interleave results instead of simple reduce | ||||
|                                           Map<String, String> res = {}; | ||||
|                                           var si = 0; | ||||
|                                           var done = false; | ||||
|                                           while (!done) { | ||||
|                                             done = true; | ||||
|                                             for (var r in results) { | ||||
|                                               if (r.length > si) { | ||||
|                                                 done = false; | ||||
|                                                 res.addEntries( | ||||
|                                                     [r.entries.elementAt(si)]); | ||||
|                                               } | ||||
|                                             } | ||||
|                                             si++; | ||||
|                                           } | ||||
|                                           List<String>? selectedUrls = res | ||||
|                                                   .isEmpty | ||||
|                                               ? [] | ||||
|                                               : await showDialog<List<String>?>( | ||||
|                                                   context: context, | ||||
|                                                   builder: (BuildContext ctx) { | ||||
|                                                     return UrlSelectionModal( | ||||
|                                                       urlsWithDescriptions: res, | ||||
|                                                       selectedByDefault: false, | ||||
|                                                       onlyOneSelectionAllowed: | ||||
|                                                           true, | ||||
|                                                     ); | ||||
|                                                   }); | ||||
|                                           if (selectedUrls != null && | ||||
|                                               selectedUrls.isNotEmpty) { | ||||
|                                             changeUserInput( | ||||
|                                                 selectedUrls[0], true, false, | ||||
|                                                 isSearch: true); | ||||
|                                           } | ||||
|                                         }).catchError((e) { | ||||
|                                           showError(e, context); | ||||
|                                         }).whenComplete(() { | ||||
|                                           setState(() { | ||||
|                                             searching = false; | ||||
|                                           }); | ||||
|                                         }); | ||||
|                         runSearch(); | ||||
|                       }, | ||||
|                 child: Text(tr('search'))) | ||||
|           ], | ||||
|                         ), | ||||
|                       if (pickedSource != null) | ||||
|                         Column( | ||||
|         ); | ||||
|  | ||||
|     Widget getAdditionalOptsCol() => Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
|             const Divider( | ||||
| @@ -338,16 +304,13 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|             Text( | ||||
|                 tr('additionalOptsFor', | ||||
|                     args: [pickedSource?.name ?? tr('source')]), | ||||
|                                 style: TextStyle( | ||||
|                                     color: | ||||
|                                         Theme.of(context).colorScheme.primary)), | ||||
|                 style: TextStyle(color: Theme.of(context).colorScheme.primary)), | ||||
|             const SizedBox( | ||||
|               height: 16, | ||||
|             ), | ||||
|             GeneratedForm( | ||||
|                 key: Key(pickedSource.runtimeType.toString()), | ||||
|                                 items: pickedSource! | ||||
|                                     .combinedAppSpecificSettingFormItems, | ||||
|                 items: pickedSource!.combinedAppSpecificSettingFormItems, | ||||
|                 onValueChanges: (values, valid, isBuilding) { | ||||
|                   if (!isBuilding) { | ||||
|                     setState(() { | ||||
| @@ -369,10 +332,9 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|                         ) | ||||
|                       else | ||||
|                         Expanded( | ||||
|                             child: Column( | ||||
|         ); | ||||
|  | ||||
|     Widget getSourcesListWidget() => Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.center, | ||||
|             mainAxisAlignment: MainAxisAlignment.center, | ||||
|             children: [ | ||||
| @@ -389,10 +351,8 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                   .map((e) => GestureDetector( | ||||
|                       onTap: e.host != null | ||||
|                           ? () { | ||||
|                                               launchUrlString( | ||||
|                                                   'https://${e.host}', | ||||
|                                                   mode: LaunchMode | ||||
|                                                       .externalApplication); | ||||
|                               launchUrlString('https://${e.host}', | ||||
|                                   mode: LaunchMode.externalApplication); | ||||
|                             } | ||||
|                           : null, | ||||
|                       child: Text( | ||||
| @@ -404,7 +364,29 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                             fontStyle: FontStyle.italic), | ||||
|                       ))) | ||||
|                   .toList() | ||||
|                             ])), | ||||
|             ]); | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(shrinkWrap: true, slivers: <Widget>[ | ||||
|           CustomAppBar(title: tr('addApp')), | ||||
|           SliverToBoxAdapter( | ||||
|             child: Padding( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
|                 child: Column( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                     children: [ | ||||
|                       getUrlInputRow(), | ||||
|                       if (shouldShowSearchBar()) | ||||
|                         const SizedBox( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                       if (shouldShowSearchBar()) getSearchBarRow(), | ||||
|                       if (pickedSource != null) | ||||
|                         getAdditionalOptsCol() | ||||
|                       else | ||||
|                         getSourcesListWidget(), | ||||
|                       const SizedBox( | ||||
|                         height: 8, | ||||
|                       ), | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/components/generated_form_modal.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/main.dart'; | ||||
| @@ -34,16 +35,22 @@ class _AppPageState extends State<AppPage> { | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     bool areDownloadsRunning = appsProvider.areDownloadsRunning(); | ||||
|  | ||||
|     var sourceProvider = SourceProvider(); | ||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||
|     var source = app != null ? sourceProvider.getSource(app.app.url) : null; | ||||
|     if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) { | ||||
|     if (!areDownloadsRunning && prevApp == null && app != null) { | ||||
|       prevApp = app; | ||||
|       getUpdate(app.app.id); | ||||
|     } | ||||
|     var trackOnly = app?.app.additionalSettings['trackOnly'] == true; | ||||
|  | ||||
|     var infoColumn = Column( | ||||
|     bool isVersionDetectionStandard = | ||||
|         app?.app.additionalSettings['versionDetection'] == | ||||
|             'standardVersionDetection'; | ||||
|  | ||||
|     getInfoColumn() => Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
| @@ -66,7 +73,8 @@ class _AppPageState extends State<AppPage> { | ||||
|               height: 32, | ||||
|             ), | ||||
|             Text( | ||||
|           tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]), | ||||
|               tr('latestVersionX', | ||||
|                   args: [app?.app.latestVersion ?? tr('unknown')]), | ||||
|               textAlign: TextAlign.center, | ||||
|               style: Theme.of(context).textTheme.bodyLarge, | ||||
|             ), | ||||
| @@ -79,6 +87,19 @@ class _AppPageState extends State<AppPage> { | ||||
|               textAlign: TextAlign.center, | ||||
|               style: Theme.of(context).textTheme.bodyLarge, | ||||
|             ), | ||||
|             if (app?.app.installedVersion != null && | ||||
|                 !isVersionDetectionStandard) | ||||
|               Column( | ||||
|                 children: [ | ||||
|                   const SizedBox( | ||||
|                     height: 4, | ||||
|                   ), | ||||
|                   Text( | ||||
|                     tr('noVersionDetection'), | ||||
|                     style: Theme.of(context).textTheme.labelSmall, | ||||
|                   ) | ||||
|                 ], | ||||
|               ), | ||||
|             const SizedBox( | ||||
|               height: 32, | ||||
|             ), | ||||
| @@ -96,8 +117,9 @@ class _AppPageState extends State<AppPage> { | ||||
|             ), | ||||
|             CategoryEditorSelector( | ||||
|                 alignment: WrapAlignment.center, | ||||
|             preselected: | ||||
|                 app?.app.categories != null ? app!.app.categories.toSet() : {}, | ||||
|                 preselected: app?.app.categories != null | ||||
|                     ? app!.app.categories.toSet() | ||||
|                     : {}, | ||||
|                 onSelected: (categories) { | ||||
|                   if (app != null) { | ||||
|                     app.app.categories = categories; | ||||
| @@ -107,7 +129,7 @@ class _AppPageState extends State<AppPage> { | ||||
|           ], | ||||
|         ); | ||||
|  | ||||
|     var fullInfoColumn = Column( | ||||
|     getFullInfoColumn() => Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|           children: [ | ||||
| @@ -125,7 +147,7 @@ class _AppPageState extends State<AppPage> { | ||||
|               height: 25, | ||||
|             ), | ||||
|             Text( | ||||
|           app?.installedInfo?.name ?? app?.app.name ?? tr('app'), | ||||
|               app?.app.name ?? tr('app'), | ||||
|               textAlign: TextAlign.center, | ||||
|               style: Theme.of(context).textTheme.displayLarge, | ||||
|             ), | ||||
| @@ -152,51 +174,164 @@ class _AppPageState extends State<AppPage> { | ||||
|             const SizedBox( | ||||
|               height: 32, | ||||
|             ), | ||||
|         infoColumn, | ||||
|             getInfoColumn(), | ||||
|             const SizedBox(height: 150) | ||||
|           ], | ||||
|         ); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: settingsProvider.showAppWebpage ? AppBar() : null, | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: RefreshIndicator( | ||||
|           child: settingsProvider.showAppWebpage | ||||
|               ? app != null | ||||
|     getAppWebView() => app != null | ||||
|         ? WebViewWidget( | ||||
|             controller: WebViewController() | ||||
|               ..setJavaScriptMode(JavaScriptMode.unrestricted) | ||||
|                         ..setBackgroundColor( | ||||
|                             Theme.of(context).colorScheme.background) | ||||
|               ..setBackgroundColor(Theme.of(context).colorScheme.background) | ||||
|               ..setJavaScriptMode(JavaScriptMode.unrestricted) | ||||
|               ..setNavigationDelegate( | ||||
|                 NavigationDelegate( | ||||
|                   onWebResourceError: (WebResourceError error) { | ||||
|                     if (error.isForMainFrame == true) { | ||||
|                       showError( | ||||
|                                     ObtainiumError(error.description, | ||||
|                                         unexpected: true), | ||||
|                           ObtainiumError(error.description, unexpected: true), | ||||
|                           context); | ||||
|                     } | ||||
|                   }, | ||||
|                 ), | ||||
|               ) | ||||
|               ..loadRequest(Uri.parse(app.app.url))) | ||||
|                   : Container() | ||||
|               : CustomScrollView( | ||||
|                   slivers: [ | ||||
|                     SliverToBoxAdapter( | ||||
|                         child: Column(children: [fullInfoColumn])), | ||||
|                   ], | ||||
|                 ), | ||||
|           onRefresh: () async { | ||||
|             if (app != null) { | ||||
|               getUpdate(app.app.id); | ||||
|         : Container(); | ||||
|  | ||||
|     showMarkUpdatedDialog() { | ||||
|       return showDialog( | ||||
|           context: context, | ||||
|           builder: (BuildContext ctx) { | ||||
|             return AlertDialog( | ||||
|               title: Text(tr('alreadyUpToDateQuestion')), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                     onPressed: () { | ||||
|                       Navigator.of(context).pop(); | ||||
|                     }, | ||||
|                     child: Text(tr('no'))), | ||||
|                 TextButton( | ||||
|                     onPressed: () { | ||||
|                       HapticFeedback.selectionClick(); | ||||
|                       var updatedApp = app?.app; | ||||
|                       if (updatedApp != null) { | ||||
|                         updatedApp.installedVersion = updatedApp.latestVersion; | ||||
|                         appsProvider.saveApps([updatedApp]); | ||||
|                       } | ||||
|           }), | ||||
|       bottomSheet: Padding( | ||||
|           padding: EdgeInsets.fromLTRB( | ||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||
|                       Navigator.of(context).pop(); | ||||
|                     }, | ||||
|                     child: Text(tr('yesMarkUpdated'))) | ||||
|               ], | ||||
|             ); | ||||
|           }); | ||||
|     } | ||||
|  | ||||
|     showAdditionalOptionsDialog() async { | ||||
|       return await showDialog<Map<String, dynamic>?>( | ||||
|           context: context, | ||||
|           builder: (BuildContext ctx) { | ||||
|             var items = | ||||
|                 (source?.combinedAppSpecificSettingFormItems ?? []).map((row) { | ||||
|               row = row.map((e) { | ||||
|                 if (app?.app.additionalSettings[e.key] != null) { | ||||
|                   e.defaultValue = app?.app.additionalSettings[e.key]; | ||||
|                 } | ||||
|                 return e; | ||||
|               }).toList(); | ||||
|               return row; | ||||
|             }).toList(); | ||||
|  | ||||
|             items = items.map((row) { | ||||
|               row = row.map((e) { | ||||
|                 if (e.key == 'versionDetection' && e is GeneratedFormDropdown) { | ||||
|                   e.disabledOptKeys ??= []; | ||||
|                   if (app?.app.installedVersion != null && | ||||
|                       app?.app.additionalSettings['versionDetection'] != | ||||
|                           'releaseDateAsVersion' && | ||||
|                       !appsProvider.isVersionDetectionPossible(app)) { | ||||
|                     e.disabledOptKeys!.add('standardVersionDetection'); | ||||
|                   } | ||||
|                   if (app?.app.releaseDate == null) { | ||||
|                     e.disabledOptKeys!.add('releaseDateAsVersion'); | ||||
|                   } | ||||
|                 } | ||||
|                 return e; | ||||
|               }).toList(); | ||||
|               return row; | ||||
|             }).toList(); | ||||
|  | ||||
|             return GeneratedFormModal( | ||||
|               title: tr('additionalOptions'), | ||||
|               items: items, | ||||
|             ); | ||||
|           }); | ||||
|     } | ||||
|  | ||||
|     handleAdditionalOptionChanges(Map<String, dynamic>? values) { | ||||
|       if (app != null && values != null) { | ||||
|         Map<String, dynamic> originalSettings = app.app.additionalSettings; | ||||
|         app.app.additionalSettings = values; | ||||
|         if (source?.enforceTrackOnly == true) { | ||||
|           app.app.additionalSettings['trackOnly'] = true; | ||||
|           // ignore: use_build_context_synchronously | ||||
|           showError(tr('appsFromSourceAreTrackOnly'), context); | ||||
|         } | ||||
|         if (app.app.additionalSettings['versionDetection'] == | ||||
|             'releaseDateAsVersion') { | ||||
|           if (originalSettings['versionDetection'] != 'releaseDateAsVersion') { | ||||
|             if (app.app.releaseDate != null) { | ||||
|               bool isUpdated = | ||||
|                   app.app.installedVersion == app.app.latestVersion; | ||||
|               app.app.latestVersion = | ||||
|                   app.app.releaseDate!.microsecondsSinceEpoch.toString(); | ||||
|               if (isUpdated) { | ||||
|                 app.app.installedVersion = app.app.latestVersion; | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } else if (originalSettings['versionDetection'] == | ||||
|             'releaseDateAsVersion') { | ||||
|           app.app.installedVersion = | ||||
|               app.installedInfo?.versionName ?? app.app.installedVersion; | ||||
|         } | ||||
|         appsProvider.saveApps([app.app]).then((value) { | ||||
|           getUpdate(app.app.id); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     getInstallOrUpdateButton() => TextButton( | ||||
|         onPressed: (app?.app.installedVersion == null || | ||||
|                     app?.app.installedVersion != app?.app.latestVersion) && | ||||
|                 !areDownloadsRunning | ||||
|             ? () async { | ||||
|                 try { | ||||
|                   HapticFeedback.heavyImpact(); | ||||
|                   if (app?.app.additionalSettings['trackOnly'] != true) { | ||||
|                     await settingsProvider.getInstallPermission(); | ||||
|                   } | ||||
|                   var res = await appsProvider.downloadAndInstallLatestApps( | ||||
|                       [app!.app.id], globalNavigatorKey.currentContext); | ||||
|                   if (res.isNotEmpty && mounted) { | ||||
|                     Navigator.of(context).pop(); | ||||
|                   } | ||||
|                 } catch (e) { | ||||
|                   showError(e, context); | ||||
|                 } | ||||
|               } | ||||
|             : null, | ||||
|         child: Text(app?.app.installedVersion == null | ||||
|             ? !trackOnly | ||||
|                 ? tr('install') | ||||
|                 : tr('markInstalled') | ||||
|             : !trackOnly | ||||
|                 ? tr('update') | ||||
|                 : tr('markUpdated'))); | ||||
|  | ||||
|     getBottomSheetMenu() => Padding( | ||||
|         padding: | ||||
|             EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||
|         child: Column( | ||||
|           mainAxisSize: MainAxisSize.min, | ||||
|           children: [ | ||||
| @@ -205,129 +340,25 @@ class _AppPageState extends State<AppPage> { | ||||
|                 child: Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                     children: [ | ||||
|                         if (app?.app.additionalSettings['versionDetection'] != | ||||
|                                 'standardVersionDetection' && | ||||
|                             !trackOnly && | ||||
|                             app?.app.installedVersion != null && | ||||
|                             app?.app.installedVersion != app?.app.latestVersion) | ||||
|                       if (app?.app.installedVersion != null && | ||||
|                           app?.app.installedVersion != app?.app.latestVersion && | ||||
|                           !isVersionDetectionStandard && | ||||
|                           !trackOnly) | ||||
|                         IconButton( | ||||
|                             onPressed: app?.downloadProgress != null | ||||
|                                 ? null | ||||
|                                   : () { | ||||
|                                       showDialog( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return AlertDialog( | ||||
|                                               title: Text(tr( | ||||
|                                                   'alreadyUpToDateQuestion')), | ||||
|                                               actions: [ | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: Text(tr('no'))), | ||||
|                                                 TextButton( | ||||
|                                                     onPressed: () { | ||||
|                                                       HapticFeedback | ||||
|                                                           .selectionClick(); | ||||
|                                                       var updatedApp = app?.app; | ||||
|                                                       if (updatedApp != null) { | ||||
|                                                         updatedApp | ||||
|                                                                 .installedVersion = | ||||
|                                                             updatedApp | ||||
|                                                                 .latestVersion; | ||||
|                                                         appsProvider.saveApps( | ||||
|                                                             [updatedApp]); | ||||
|                                                       } | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
|                                                     }, | ||||
|                                                     child: Text( | ||||
|                                                         tr('yesMarkUpdated'))) | ||||
|                                               ], | ||||
|                                             ); | ||||
|                                           }); | ||||
|                                     }, | ||||
|                                 : showMarkUpdatedDialog, | ||||
|                             tooltip: tr('markUpdated'), | ||||
|                             icon: const Icon(Icons.done)), | ||||
|                       if (source != null && | ||||
|                             source | ||||
|                                 .combinedAppSpecificSettingFormItems.isNotEmpty) | ||||
|                           source.combinedAppSpecificSettingFormItems.isNotEmpty) | ||||
|                         IconButton( | ||||
|                             onPressed: app?.downloadProgress != null | ||||
|                                 ? null | ||||
|                                   : () { | ||||
|                                       showDialog<Map<String, dynamic>?>( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             var items = source | ||||
|                                                 .combinedAppSpecificSettingFormItems | ||||
|                                                 .map((row) { | ||||
|                                               row.map((e) { | ||||
|                                                 if (app?.app.additionalSettings[ | ||||
|                                                         e.key] != | ||||
|                                                     null) { | ||||
|                                                   e.defaultValue = app?.app | ||||
|                                                           .additionalSettings[ | ||||
|                                                       e.key]; | ||||
|                                                 } | ||||
|                                                 return e; | ||||
|                                               }).toList(); | ||||
|                                               return row; | ||||
|                                             }).toList(); | ||||
|                                             return GeneratedFormModal( | ||||
|                                               title: tr('additionalOptions'), | ||||
|                                               items: items, | ||||
|                                             ); | ||||
|                                           }).then((values) { | ||||
|                                         if (app != null && values != null) { | ||||
|                                           Map<String, dynamic> | ||||
|                                               originalSettings = | ||||
|                                               app.app.additionalSettings; | ||||
|                                           app.app.additionalSettings = values; | ||||
|                                           if (source.enforceTrackOnly) { | ||||
|                                             app.app.additionalSettings[ | ||||
|                                                 'trackOnly'] = true; | ||||
|                                             showError( | ||||
|                                                 tr('appsFromSourceAreTrackOnly'), | ||||
|                                                 context); | ||||
|                                           } | ||||
|                                           if (app.app.additionalSettings[ | ||||
|                                                   'versionDetection'] == | ||||
|                                               'releaseDateAsVersion') { | ||||
|                                             if (originalSettings[ | ||||
|                                                     'versionDetection'] != | ||||
|                                                 'releaseDateAsVersion') { | ||||
|                                               if (app.app.releaseDate != null) { | ||||
|                                                 bool isUpdated = | ||||
|                                                     app.app.installedVersion == | ||||
|                                                         app.app.latestVersion; | ||||
|                                                 app.app.latestVersion = app | ||||
|                                                     .app | ||||
|                                                     .releaseDate! | ||||
|                                                     .microsecondsSinceEpoch | ||||
|                                                     .toString(); | ||||
|                                                 if (isUpdated) { | ||||
|                                                   app.app.installedVersion = | ||||
|                                                       app.app.latestVersion; | ||||
|                                                 } | ||||
|                                               } | ||||
|                                             } | ||||
|                                           } else if (originalSettings[ | ||||
|                                                   'versionDetection'] == | ||||
|                                               'releaseDateAsVersion') { | ||||
|                                             app.app.installedVersion = app | ||||
|                                                     .installedInfo | ||||
|                                                     ?.versionName ?? | ||||
|                                                 app.app.installedVersion; | ||||
|                                           } | ||||
|                                           appsProvider.saveApps([app.app]).then( | ||||
|                                               (value) { | ||||
|                                             getUpdate(app.app.id); | ||||
|                                           }); | ||||
|                                         } | ||||
|                                       }); | ||||
|                                 : () async { | ||||
|                                     var values = | ||||
|                                         await showAdditionalOptionsDialog(); | ||||
|                                     handleAdditionalOptionChanges(values); | ||||
|                                   }, | ||||
|                             tooltip: tr('additionalOptions'), | ||||
|                             icon: const Icon(Icons.edit)), | ||||
| @@ -347,7 +378,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                                   builder: (BuildContext ctx) { | ||||
|                                     return AlertDialog( | ||||
|                                       scrollable: true, | ||||
|                                         content: infoColumn, | ||||
|                                       content: getInfoColumn(), | ||||
|                                       title: Text( | ||||
|                                           '${app.app.name} ${tr('byX', args: [ | ||||
|                                             app.app.author | ||||
| @@ -365,47 +396,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                             icon: const Icon(Icons.more_horiz), | ||||
|                             tooltip: tr('more')), | ||||
|                       const SizedBox(width: 16.0), | ||||
|                         Expanded( | ||||
|                             child: TextButton( | ||||
|                                 onPressed: (app?.app.installedVersion == null || | ||||
|                                             app?.app.installedVersion != | ||||
|                                                 app?.app.latestVersion) && | ||||
|                                         !appsProvider.areDownloadsRunning() | ||||
|                                     ? () { | ||||
|                                         HapticFeedback.heavyImpact(); | ||||
|                                         () async { | ||||
|                                           if (app?.app.additionalSettings[ | ||||
|                                                   'trackOnly'] != | ||||
|                                               true) { | ||||
|                                             await settingsProvider | ||||
|                                                 .getInstallPermission(); | ||||
|                                           } | ||||
|                                         }() | ||||
|                                             .then((value) { | ||||
|                                           appsProvider | ||||
|                                               .downloadAndInstallLatestApps( | ||||
|                                                   [app!.app.id], | ||||
|                                                   globalNavigatorKey | ||||
|                                                       .currentContext).then( | ||||
|                                                   (res) { | ||||
|                                             if (res.isNotEmpty && mounted) { | ||||
|                                               Navigator.of(context).pop(); | ||||
|                                             } | ||||
|                                           }).catchError((e) { | ||||
|                                             showError(e, context); | ||||
|                                           }); | ||||
|                                         }).catchError((e) { | ||||
|                                           showError(e, context); | ||||
|                                         }); | ||||
|                                       } | ||||
|                                     : null, | ||||
|                                 child: Text(app?.app.installedVersion == null | ||||
|                                     ? !trackOnly | ||||
|                                         ? tr('install') | ||||
|                                         : tr('markInstalled') | ||||
|                                     : !trackOnly | ||||
|                                         ? tr('update') | ||||
|                                         : tr('markUpdated')))), | ||||
|                       Expanded(child: getInstallOrUpdateButton()), | ||||
|                       const SizedBox(width: 16.0), | ||||
|                       Expanded( | ||||
|                           child: TextButton( | ||||
| @@ -433,7 +424,25 @@ class _AppPageState extends State<AppPage> { | ||||
|                   child: LinearProgressIndicator( | ||||
|                       value: app!.downloadProgress! / 100)) | ||||
|           ], | ||||
|           )), | ||||
|     ); | ||||
|         )); | ||||
|  | ||||
|     return Scaffold( | ||||
|         appBar: settingsProvider.showAppWebpage ? AppBar() : null, | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: RefreshIndicator( | ||||
|             child: settingsProvider.showAppWebpage | ||||
|                 ? getAppWebView() | ||||
|                 : CustomScrollView( | ||||
|                     slivers: [ | ||||
|                       SliverToBoxAdapter( | ||||
|                           child: Column(children: [getFullInfoColumn()])), | ||||
|                     ], | ||||
|                   ), | ||||
|             onRefresh: () async { | ||||
|               if (app != null) { | ||||
|                 getUpdate(app.app.id); | ||||
|               } | ||||
|             }), | ||||
|         bottomSheet: getBottomSheetMenu()); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										1240
									
								
								lib/pages/apps.dart
									
									
									
									
									
								
							
							
						
						
									
										1240
									
								
								lib/pages/apps.dart
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -30,6 +30,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     var appsProvider = context.read<AppsProvider>(); | ||||
|     var settingsProvider = context.read<SettingsProvider>(); | ||||
|  | ||||
|     var outlineButtonStyle = ButtonStyle( | ||||
|       shape: MaterialStateProperty.all( | ||||
|         StadiumBorder( | ||||
| @@ -101,6 +102,193 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     runObtainiumExport() { | ||||
|       HapticFeedback.selectionClick(); | ||||
|       appsProvider.exportApps().then((String path) { | ||||
|         showError(tr('exportedTo', args: [path]), context); | ||||
|       }).catchError((e) { | ||||
|         showError(e, context); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     runObtainiumImport() { | ||||
|       HapticFeedback.selectionClick(); | ||||
|       FilePicker.platform.pickFiles().then((result) { | ||||
|         setState(() { | ||||
|           importInProgress = true; | ||||
|         }); | ||||
|         if (result != null) { | ||||
|           String data = File(result.files.single.path!).readAsStringSync(); | ||||
|           try { | ||||
|             jsonDecode(data); | ||||
|           } catch (e) { | ||||
|             throw ObtainiumError(tr('invalidInput')); | ||||
|           } | ||||
|           appsProvider.importApps(data).then((value) { | ||||
|             var cats = settingsProvider.categories; | ||||
|             appsProvider.apps.forEach((key, value) { | ||||
|               for (var c in value.app.categories) { | ||||
|                 if (!cats.containsKey(c)) { | ||||
|                   cats[c] = generateRandomLightColor().value; | ||||
|                 } | ||||
|               } | ||||
|             }); | ||||
|             appsProvider.addMissingCategories(settingsProvider); | ||||
|             showError(tr('importedX', args: [plural('apps', value)]), context); | ||||
|           }); | ||||
|         } else { | ||||
|           // User canceled the picker | ||||
|         } | ||||
|       }).catchError((e) { | ||||
|         showError(e, context); | ||||
|       }).whenComplete(() { | ||||
|         setState(() { | ||||
|           importInProgress = false; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     runUrlImport() { | ||||
|       FilePicker.platform.pickFiles().then((result) { | ||||
|         if (result != null) { | ||||
|           urlListImport( | ||||
|               overrideInitValid: true, | ||||
|               initValue: RegExp('https?://[^"]+') | ||||
|                   .allMatches( | ||||
|                       File(result.files.single.path!).readAsStringSync()) | ||||
|                   .map((e) => e.input.substring(e.start, e.end)) | ||||
|                   .toSet() | ||||
|                   .toList() | ||||
|                   .where((url) { | ||||
|                 try { | ||||
|                   sourceProvider.getSource(url); | ||||
|                   return true; | ||||
|                 } catch (e) { | ||||
|                   return false; | ||||
|                 } | ||||
|               }).join('\n')); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     runSourceSearch(AppSource source) { | ||||
|       () async { | ||||
|         var values = await showDialog<Map<String, dynamic>?>( | ||||
|             context: context, | ||||
|             builder: (BuildContext ctx) { | ||||
|               return GeneratedFormModal( | ||||
|                 title: tr('searchX', args: [source.name]), | ||||
|                 items: [ | ||||
|                   [ | ||||
|                     GeneratedFormTextField('searchQuery', | ||||
|                         label: tr('searchQuery')) | ||||
|                   ] | ||||
|                 ], | ||||
|               ); | ||||
|             }); | ||||
|         if (values != null && | ||||
|             (values['searchQuery'] as String?)?.isNotEmpty == true) { | ||||
|           setState(() { | ||||
|             importInProgress = true; | ||||
|           }); | ||||
|           var urlsWithDescriptions = | ||||
|               await source.search(values['searchQuery'] as String); | ||||
|           if (urlsWithDescriptions.isNotEmpty) { | ||||
|             var selectedUrls = | ||||
|                 // ignore: use_build_context_synchronously | ||||
|                 await showDialog<List<String>?>( | ||||
|                     context: context, | ||||
|                     builder: (BuildContext ctx) { | ||||
|                       return UrlSelectionModal( | ||||
|                         urlsWithDescriptions: urlsWithDescriptions, | ||||
|                         selectedByDefault: false, | ||||
|                       ); | ||||
|                     }); | ||||
|             if (selectedUrls != null && selectedUrls.isNotEmpty) { | ||||
|               var errors = await appsProvider.addAppsByURL(selectedUrls); | ||||
|               if (errors.isEmpty) { | ||||
|                 // ignore: use_build_context_synchronously | ||||
|                 showError( | ||||
|                     tr('importedX', args: [plural('app', selectedUrls.length)]), | ||||
|                     context); | ||||
|               } else { | ||||
|                 // ignore: use_build_context_synchronously | ||||
|                 showDialog( | ||||
|                     context: context, | ||||
|                     builder: (BuildContext ctx) { | ||||
|                       return ImportErrorDialog( | ||||
|                           urlsLength: selectedUrls.length, errors: errors); | ||||
|                     }); | ||||
|               } | ||||
|             } | ||||
|           } else { | ||||
|             throw ObtainiumError(tr('noResults')); | ||||
|           } | ||||
|         } | ||||
|       }() | ||||
|           .catchError((e) { | ||||
|         showError(e, context); | ||||
|       }).whenComplete(() { | ||||
|         setState(() { | ||||
|           importInProgress = false; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     runMassSourceImport(MassAppUrlSource source) { | ||||
|       () async { | ||||
|         var values = await showDialog<Map<String, dynamic>?>( | ||||
|             context: context, | ||||
|             builder: (BuildContext ctx) { | ||||
|               return GeneratedFormModal( | ||||
|                 title: tr('importX', args: [source.name]), | ||||
|                 items: source.requiredArgs | ||||
|                     .map((e) => [GeneratedFormTextField(e, label: e)]) | ||||
|                     .toList(), | ||||
|               ); | ||||
|             }); | ||||
|         if (values != null) { | ||||
|           setState(() { | ||||
|             importInProgress = true; | ||||
|           }); | ||||
|           var urlsWithDescriptions = await source.getUrlsWithDescriptions( | ||||
|               values.values.map((e) => e.toString()).toList()); | ||||
|           var selectedUrls = | ||||
|               // ignore: use_build_context_synchronously | ||||
|               await showDialog<List<String>?>( | ||||
|                   context: context, | ||||
|                   builder: (BuildContext ctx) { | ||||
|                     return UrlSelectionModal( | ||||
|                         urlsWithDescriptions: urlsWithDescriptions); | ||||
|                   }); | ||||
|           if (selectedUrls != null) { | ||||
|             var errors = await appsProvider.addAppsByURL(selectedUrls); | ||||
|             if (errors.isEmpty) { | ||||
|               // ignore: use_build_context_synchronously | ||||
|               showError( | ||||
|                   tr('importedX', args: [plural('app', selectedUrls.length)]), | ||||
|                   context); | ||||
|             } else { | ||||
|               // ignore: use_build_context_synchronously | ||||
|               showDialog( | ||||
|                   context: context, | ||||
|                   builder: (BuildContext ctx) { | ||||
|                     return ImportErrorDialog( | ||||
|                         urlsLength: selectedUrls.length, errors: errors); | ||||
|                   }); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }() | ||||
|           .catchError((e) { | ||||
|         showError(e, context); | ||||
|       }).whenComplete(() { | ||||
|         setState(() { | ||||
|           importInProgress = false; | ||||
|         }); | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
| @@ -120,18 +308,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                   onPressed: appsProvider.apps.isEmpty || | ||||
|                                           importInProgress | ||||
|                                       ? null | ||||
|                                       : () { | ||||
|                                           HapticFeedback.selectionClick(); | ||||
|                                           appsProvider | ||||
|                                               .exportApps() | ||||
|                                               .then((String path) { | ||||
|                                             showError( | ||||
|                                                 tr('exportedTo', args: [path]), | ||||
|                                                 context); | ||||
|                                           }).catchError((e) { | ||||
|                                             showError(e, context); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                       : runObtainiumExport, | ||||
|                                   child: Text(tr('obtainiumExport')))), | ||||
|                           const SizedBox( | ||||
|                             width: 16, | ||||
| @@ -141,59 +318,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                   style: outlineButtonStyle, | ||||
|                                   onPressed: importInProgress | ||||
|                                       ? null | ||||
|                                       : () { | ||||
|                                           HapticFeedback.selectionClick(); | ||||
|                                           FilePicker.platform | ||||
|                                               .pickFiles() | ||||
|                                               .then((result) { | ||||
|                                             setState(() { | ||||
|                                               importInProgress = true; | ||||
|                                             }); | ||||
|                                             if (result != null) { | ||||
|                                               String data = File( | ||||
|                                                       result.files.single.path!) | ||||
|                                                   .readAsStringSync(); | ||||
|                                               try { | ||||
|                                                 jsonDecode(data); | ||||
|                                               } catch (e) { | ||||
|                                                 throw ObtainiumError( | ||||
|                                                     tr('invalidInput')); | ||||
|                                               } | ||||
|                                               appsProvider | ||||
|                                                   .importApps(data) | ||||
|                                                   .then((value) { | ||||
|                                                 var cats = | ||||
|                                                     settingsProvider.categories; | ||||
|                                                 appsProvider.apps | ||||
|                                                     .forEach((key, value) { | ||||
|                                                   for (var c | ||||
|                                                       in value.app.categories) { | ||||
|                                                     if (!cats.containsKey(c)) { | ||||
|                                                       cats[c] = | ||||
|                                                           generateRandomLightColor() | ||||
|                                                               .value; | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }); | ||||
|                                                 settingsProvider.categories = | ||||
|                                                     cats; | ||||
|                                                 showError( | ||||
|                                                     tr('importedX', args: [ | ||||
|                                                       plural('apps', value) | ||||
|                                                     ]), | ||||
|                                                     context); | ||||
|                                               }); | ||||
|                                             } else { | ||||
|                                               // User canceled the picker | ||||
|                                             } | ||||
|                                           }).catchError((e) { | ||||
|                                             showError(e, context); | ||||
|                                           }).whenComplete(() { | ||||
|                                             setState(() { | ||||
|                                               importInProgress = false; | ||||
|                                             }); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                       : runObtainiumImport, | ||||
|                                   child: Text(tr('obtainiumImport')))) | ||||
|                         ], | ||||
|                       ), | ||||
| @@ -216,49 +341,15 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                               height: 32, | ||||
|                             ), | ||||
|                             TextButton( | ||||
|                                 onPressed: importInProgress | ||||
|                                     ? null | ||||
|                                     : () { | ||||
|                                         urlListImport(); | ||||
|                                       }, | ||||
|                                 onPressed: | ||||
|                                     importInProgress ? null : urlListImport, | ||||
|                                 child: Text( | ||||
|                                   tr('importFromURLList'), | ||||
|                                 )), | ||||
|                             const SizedBox(height: 8), | ||||
|                             TextButton( | ||||
|                                 onPressed: importInProgress | ||||
|                                     ? null | ||||
|                                     : () { | ||||
|                                         FilePicker.platform | ||||
|                                             .pickFiles() | ||||
|                                             .then((result) { | ||||
|                                           if (result != null) { | ||||
|                                             urlListImport( | ||||
|                                                 overrideInitValid: true, | ||||
|                                                 initValue: | ||||
|                                                     RegExp('https?://[^"]+') | ||||
|                                                         .allMatches(File(result | ||||
|                                                                 .files | ||||
|                                                                 .single | ||||
|                                                                 .path!) | ||||
|                                                             .readAsStringSync()) | ||||
|                                                         .map((e) => | ||||
|                                                             e.input.substring( | ||||
|                                                                 e.start, e.end)) | ||||
|                                                         .toSet() | ||||
|                                                         .toList() | ||||
|                                                         .where((url) { | ||||
|                                                   try { | ||||
|                                                     sourceProvider | ||||
|                                                         .getSource(url); | ||||
|                                                     return true; | ||||
|                                                   } catch (e) { | ||||
|                                                     return false; | ||||
|                                                   } | ||||
|                                                 }).join('\n')); | ||||
|                                           } | ||||
|                                         }); | ||||
|                                       }, | ||||
|                                 onPressed: | ||||
|                                     importInProgress ? null : runUrlImport, | ||||
|                                 child: Text( | ||||
|                                   tr('importFromURLsInFile'), | ||||
|                                 )), | ||||
| @@ -275,106 +366,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 () async { | ||||
|                                                   var values = await showDialog< | ||||
|                                                           Map<String, | ||||
|                                                               dynamic>?>( | ||||
|                                                       context: context, | ||||
|                                                       builder: | ||||
|                                                           (BuildContext ctx) { | ||||
|                                                         return GeneratedFormModal( | ||||
|                                                           title: tr('searchX', | ||||
|                                                               args: [ | ||||
|                                                                 source.name | ||||
|                                                               ]), | ||||
|                                                           items: [ | ||||
|                                                             [ | ||||
|                                                               GeneratedFormTextField( | ||||
|                                                                   'searchQuery', | ||||
|                                                                   label: tr( | ||||
|                                                                       'searchQuery')) | ||||
|                                                             ] | ||||
|                                                           ], | ||||
|                                                         ); | ||||
|                                                       }); | ||||
|                                                   if (values != null && | ||||
|                                                       (values['searchQuery'] | ||||
|                                                                   as String?) | ||||
|                                                               ?.isNotEmpty == | ||||
|                                                           true) { | ||||
|                                                     setState(() { | ||||
|                                                       importInProgress = true; | ||||
|                                                     }); | ||||
|                                                     var urlsWithDescriptions = | ||||
|                                                         await source.search( | ||||
|                                                             values['searchQuery'] | ||||
|                                                                 as String); | ||||
|                                                     if (urlsWithDescriptions | ||||
|                                                         .isNotEmpty) { | ||||
|                                                       var selectedUrls = | ||||
|                                                           // ignore: use_build_context_synchronously | ||||
|                                                           await showDialog< | ||||
|                                                                   List< | ||||
|                                                                       String>?>( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return UrlSelectionModal( | ||||
|                                                                   urlsWithDescriptions: | ||||
|                                                                       urlsWithDescriptions, | ||||
|                                                                   selectedByDefault: | ||||
|                                                                       false, | ||||
|                                                                 ); | ||||
|                                                               }); | ||||
|                                                       if (selectedUrls != | ||||
|                                                               null && | ||||
|                                                           selectedUrls | ||||
|                                                               .isNotEmpty) { | ||||
|                                                         var errors = | ||||
|                                                             await appsProvider | ||||
|                                                                 .addAppsByURL( | ||||
|                                                                     selectedUrls); | ||||
|                                                         if (errors.isEmpty) { | ||||
|                                                           // ignore: use_build_context_synchronously | ||||
|                                                           showError( | ||||
|                                                               tr('importedX', | ||||
|                                                                   args: [ | ||||
|                                                                     plural( | ||||
|                                                                         'app', | ||||
|                                                                         selectedUrls | ||||
|                                                                             .length) | ||||
|                                                                   ]), | ||||
|                                                               context); | ||||
|                                                         } else { | ||||
|                                                           // ignore: use_build_context_synchronously | ||||
|                                                           showDialog( | ||||
|                                                               context: context, | ||||
|                                                               builder: | ||||
|                                                                   (BuildContext | ||||
|                                                                       ctx) { | ||||
|                                                                 return ImportErrorDialog( | ||||
|                                                                     urlsLength: | ||||
|                                                                         selectedUrls | ||||
|                                                                             .length, | ||||
|                                                                     errors: | ||||
|                                                                         errors); | ||||
|                                                               }); | ||||
|                                                         } | ||||
|                                                       } | ||||
|                                                     } else { | ||||
|                                                       throw ObtainiumError( | ||||
|                                                           tr('noResults')); | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }() | ||||
|                                                     .catchError((e) { | ||||
|                                                   showError(e, context); | ||||
|                                                 }).whenComplete(() { | ||||
|                                                   setState(() { | ||||
|                                                     importInProgress = false; | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                                 runSourceSearch(source); | ||||
|                                               }, | ||||
|                                         child: Text( | ||||
|                                             tr('searchX', args: [source.name]))) | ||||
| @@ -390,93 +382,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                         onPressed: importInProgress | ||||
|                                             ? null | ||||
|                                             : () { | ||||
|                                                 () async { | ||||
|                                                   var values = await showDialog< | ||||
|                                                           Map<String, | ||||
|                                                               dynamic>?>( | ||||
|                                                       context: context, | ||||
|                                                       builder: | ||||
|                                                           (BuildContext ctx) { | ||||
|                                                         return GeneratedFormModal( | ||||
|                                                           title: tr('importX', | ||||
|                                                               args: [ | ||||
|                                                                 source.name | ||||
|                                                               ]), | ||||
|                                                           items: | ||||
|                                                               source | ||||
|                                                                   .requiredArgs | ||||
|                                                                   .map( | ||||
|                                                                       (e) => [ | ||||
|                                                                             GeneratedFormTextField(e, | ||||
|                                                                                 label: e) | ||||
|                                                                           ]) | ||||
|                                                                   .toList(), | ||||
|                                                         ); | ||||
|                                                       }); | ||||
|                                                   if (values != null) { | ||||
|                                                     setState(() { | ||||
|                                                       importInProgress = true; | ||||
|                                                     }); | ||||
|                                                     var urlsWithDescriptions = | ||||
|                                                         await source | ||||
|                                                             .getUrlsWithDescriptions( | ||||
|                                                                 values.values | ||||
|                                                                     .map((e) => | ||||
|                                                                         e.toString()) | ||||
|                                                                     .toList()); | ||||
|                                                     var selectedUrls = | ||||
|                                                         // ignore: use_build_context_synchronously | ||||
|                                                         await showDialog< | ||||
|                                                                 List<String>?>( | ||||
|                                                             context: context, | ||||
|                                                             builder: | ||||
|                                                                 (BuildContext | ||||
|                                                                     ctx) { | ||||
|                                                               return UrlSelectionModal( | ||||
|                                                                   urlsWithDescriptions: | ||||
|                                                                       urlsWithDescriptions); | ||||
|                                                             }); | ||||
|                                                     if (selectedUrls != null) { | ||||
|                                                       var errors = | ||||
|                                                           await appsProvider | ||||
|                                                               .addAppsByURL( | ||||
|                                                                   selectedUrls); | ||||
|                                                       if (errors.isEmpty) { | ||||
|                                                         // ignore: use_build_context_synchronously | ||||
|                                                         showError( | ||||
|                                                             tr('importedX', | ||||
|                                                                 args: [ | ||||
|                                                                   plural( | ||||
|                                                                       'app', | ||||
|                                                                       selectedUrls | ||||
|                                                                           .length) | ||||
|                                                                 ]), | ||||
|                                                             context); | ||||
|                                                       } else { | ||||
|                                                         // ignore: use_build_context_synchronously | ||||
|                                                         showDialog( | ||||
|                                                             context: context, | ||||
|                                                             builder: | ||||
|                                                                 (BuildContext | ||||
|                                                                     ctx) { | ||||
|                                                               return ImportErrorDialog( | ||||
|                                                                   urlsLength: | ||||
|                                                                       selectedUrls | ||||
|                                                                           .length, | ||||
|                                                                   errors: | ||||
|                                                                       errors); | ||||
|                                                             }); | ||||
|                                                       } | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }() | ||||
|                                                     .catchError((e) { | ||||
|                                                   showError(e, context); | ||||
|                                                 }).whenComplete(() { | ||||
|                                                   setState(() { | ||||
|                                                     importInProgress = false; | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                                 runMassSourceImport(source); | ||||
|                                               }, | ||||
|                                         child: Text( | ||||
|                                             tr('importX', args: [source.name]))) | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/main.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/logs_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| @@ -262,6 +263,18 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Row( | ||||
|                               mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                               children: [ | ||||
|                                 Text(tr('groupByCategory')), | ||||
|                                 Switch( | ||||
|                                     value: settingsProvider.groupByCategory, | ||||
|                                     onChanged: (value) { | ||||
|                                       settingsProvider.groupByCategory = value; | ||||
|                                     }) | ||||
|                               ], | ||||
|                             ), | ||||
|                             const Divider( | ||||
|                               height: 16, | ||||
|                             ), | ||||
| @@ -432,6 +445,7 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     storedValues = settingsProvider.categories.map((key, value) => MapEntry( | ||||
|         key, | ||||
|         MapEntry(value, | ||||
| @@ -455,8 +469,9 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> { | ||||
|           if (!isBuilding) { | ||||
|             storedValues = | ||||
|                 values['categories'] as Map<String, MapEntry<int, bool>>; | ||||
|             settingsProvider.categories = | ||||
|                 storedValues.map((key, value) => MapEntry(key, value.key)); | ||||
|             settingsProvider.setCategories( | ||||
|                 storedValues.map((key, value) => MapEntry(key, value.key)), | ||||
|                 appsProvider: appsProvider); | ||||
|             if (widget.onSelected != null) { | ||||
|               widget.onSelected!(storedValues.keys | ||||
|                   .where((k) => storedValues[k]!.value) | ||||
|   | ||||
| @@ -73,6 +73,18 @@ List<String> generateStandardVersionRegExStrings() { | ||||
| List<String> standardVersionRegExStrings = | ||||
|     generateStandardVersionRegExStrings(); | ||||
|  | ||||
| Set<String> findStandardFormatsForVersion(String version, bool strict) { | ||||
|   // If !strict, even a substring match is valid | ||||
|   Set<String> results = {}; | ||||
|   for (var pattern in standardVersionRegExStrings) { | ||||
|     if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}') | ||||
|         .hasMatch(version)) { | ||||
|       results.add(pattern); | ||||
|     } | ||||
|   } | ||||
|   return results; | ||||
| } | ||||
|  | ||||
| class AppsProvider with ChangeNotifier { | ||||
|   // In memory App state (should always be kept in sync with local storage versions) | ||||
|   Map<String, AppInMemory> apps = {}; | ||||
| @@ -472,55 +484,110 @@ class AppsProvider with ChangeNotifier { | ||||
|     return res; | ||||
|   } | ||||
|  | ||||
|   // If the App says it is installed but installedInfo is null, set it to not installed | ||||
|   // If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently | ||||
|   // If that fails, just set it to the actual version string (all we can do at that point) | ||||
|   // Don't save changes, just return the object if changes were made (else null) | ||||
|   bool isVersionDetectionPossible(AppInMemory? app) { | ||||
|     return app?.app.additionalSettings['trackOnly'] != true && | ||||
|         app?.app.additionalSettings['versionDetection'] != | ||||
|             'releaseDateAsVersion' && | ||||
|         app?.installedInfo?.versionName != null && | ||||
|         app?.app.installedVersion != null && | ||||
|         reconcileVersionDifferences( | ||||
|                 app!.installedInfo!.versionName!, app.app.installedVersion!) != | ||||
|             null; | ||||
|   } | ||||
|  | ||||
|   // Given an App and it's on-device info... | ||||
|   // Reconcile unexpected differences between its reported installed version, real installed version, and reported latest version | ||||
|   App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { | ||||
|     var modded = false; | ||||
|     var trackOnly = app.additionalSettings['trackOnly'] == true; | ||||
|     var noVersionDetection = app.additionalSettings['versionDetection'] != | ||||
|         'standardVersionDetection'; | ||||
|     // FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL | ||||
|     if (installedInfo == null && app.installedVersion != null && !trackOnly) { | ||||
|       // App says it's installed but isn't really (and isn't track only) - set to not installed | ||||
|       app.installedVersion = null; | ||||
|       modded = true; | ||||
|     } else if (installedInfo?.versionName != null && | ||||
|         app.installedVersion == null) { | ||||
|       // App says it's not installed but really is - set to installed and use real package versionName | ||||
|       app.installedVersion = installedInfo!.versionName; | ||||
|       modded = true; | ||||
|     } else if (installedInfo?.versionName != null && | ||||
|     } | ||||
|     // SECOND, RECONCILE DIFFERENCES BETWEEN THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE NEITHER IS NULL | ||||
|     if (installedInfo?.versionName != null && | ||||
|         installedInfo!.versionName != app.installedVersion && | ||||
|         !noVersionDetection) { | ||||
|       String? correctedInstalledVersion = reconcileRealAndInternalVersions( | ||||
|       // App's reported version and real version don't match (and it uses standard version detection) | ||||
|       // If they share a standard format (and are still different under it), update the reported version accordingly | ||||
|       var correctedInstalledVersion = reconcileVersionDifferences( | ||||
|           installedInfo.versionName!, app.installedVersion!); | ||||
|       if (correctedInstalledVersion != null) { | ||||
|         app.installedVersion = correctedInstalledVersion; | ||||
|       if (correctedInstalledVersion?.key == false) { | ||||
|         app.installedVersion = correctedInstalledVersion!.value; | ||||
|         modded = true; | ||||
|       } | ||||
|     } | ||||
|     // THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS | ||||
|     if (app.installedVersion != null && | ||||
|         app.installedVersion != app.latestVersion && | ||||
|         !noVersionDetection) { | ||||
|       app.installedVersion = reconcileRealAndInternalVersions( | ||||
|               app.installedVersion!, app.latestVersion, | ||||
|               matchMode: true) ?? | ||||
|           app.installedVersion; | ||||
|       // App's reported installed and latest versions don't match (and it uses standard version detection) | ||||
|       // If they share a standard format, make sure the App's reported installed version uses that format | ||||
|       var correctedInstalledVersion = | ||||
|           reconcileVersionDifferences(app.installedVersion!, app.latestVersion); | ||||
|       if (correctedInstalledVersion?.key == true) { | ||||
|         app.installedVersion = correctedInstalledVersion!.value; | ||||
|         modded = true; | ||||
|       } | ||||
|     } | ||||
|     // FOURTH, DISABLE VERSION DETECTION IF ENABLED AND THE REPORTED/REAL INSTALLED VERSIONS ARE NOT STANDARDIZED | ||||
|     if (installedInfo != null && | ||||
|         app.additionalSettings['versionDetection'] == | ||||
|             'standardVersionDetection' && | ||||
|         !isVersionDetectionPossible(AppInMemory(app, null, installedInfo))) { | ||||
|       app.additionalSettings['versionDetection'] = 'noVersionDetection'; | ||||
|       logs.add('Could not reconcile version formats for: ${app.id}'); | ||||
|       modded = true; | ||||
|     } | ||||
|     // if (app.installedVersion != null && | ||||
|     //     app.additionalSettings['versionDetection'] == | ||||
|     //         'standardVersionDetection') { | ||||
|     //   var correctedInstalledVersion = | ||||
|     //       reconcileVersionDifferences(app.installedVersion!, app.latestVersion); | ||||
|     //   if (correctedInstalledVersion == null) { | ||||
|     //     app.additionalSettings['versionDetection'] = 'noVersionDetection'; | ||||
|     //     logs.add('Could not reconcile version formats for: ${app.id}'); | ||||
|     //     modded = true; | ||||
|     //   } | ||||
|     // } | ||||
|  | ||||
|     return modded ? app : null; | ||||
|   } | ||||
|  | ||||
|   String? reconcileRealAndInternalVersions( | ||||
|       String realVersion, String internalVersion, | ||||
|       {bool matchMode = false}) { | ||||
|     // 1. If one or both of these can't be converted to a "standard" format, return null (leave as is) | ||||
|     // 2. If both have a "standard" format under which they are equal, return null (leave as is) | ||||
|     // 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally) | ||||
|     // If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly | ||||
|     // Matchmode to be used when comparing internal install version and internal latest version | ||||
|   MapEntry<bool, String>? reconcileVersionDifferences( | ||||
|       String templateVersion, String comparisonVersion) { | ||||
|     // Returns null if the versions don't share a common standard format | ||||
|     // Returns <true, comparisonVersion> if they share a common format and are equal | ||||
|     // Returns <false, templateVersion> if they share a common format but are not equal | ||||
|     // templateVersion must fully match a standard format, while comparisonVersion can have a substring match | ||||
|     var templateVersionFormats = | ||||
|         findStandardFormatsForVersion(templateVersion, true); | ||||
|     var comparisonVersionFormats = | ||||
|         findStandardFormatsForVersion(comparisonVersion, false); | ||||
|     var commonStandardFormats = | ||||
|         templateVersionFormats.intersection(comparisonVersionFormats); | ||||
|     if (commonStandardFormats.isEmpty) { | ||||
|       return null; | ||||
|     } | ||||
|     for (String pattern in commonStandardFormats) { | ||||
|       if (doStringsMatchUnderRegEx( | ||||
|           pattern, comparisonVersion, templateVersion)) { | ||||
|         return MapEntry(true, comparisonVersion); | ||||
|       } | ||||
|     } | ||||
|     return MapEntry(false, templateVersion); | ||||
|   } | ||||
|  | ||||
|     bool doStringsMatchUnderRegEx( | ||||
|         String pattern, String value1, String value2) { | ||||
|   bool doStringsMatchUnderRegEx(String pattern, String value1, String value2) { | ||||
|     var r = RegExp(pattern); | ||||
|     var m1 = r.firstMatch(value1); | ||||
|     var m2 = r.firstMatch(value2); | ||||
| @@ -530,38 +597,6 @@ class AppsProvider with ChangeNotifier { | ||||
|         : false; | ||||
|   } | ||||
|  | ||||
|     Set<String> findStandardFormatsForVersion(String version, bool strict) { | ||||
|       Set<String> results = {}; | ||||
|       for (var pattern in standardVersionRegExStrings) { | ||||
|         if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}') | ||||
|             .hasMatch(version)) { | ||||
|           results.add(pattern); | ||||
|         } | ||||
|       } | ||||
|       return results; | ||||
|     } | ||||
|  | ||||
|     var realStandardVersionFormats = | ||||
|         findStandardFormatsForVersion(realVersion, true); | ||||
|     var internalStandardVersionFormats = | ||||
|         findStandardFormatsForVersion(internalVersion, false); | ||||
|     var commonStandardFormats = | ||||
|         realStandardVersionFormats.intersection(internalStandardVersionFormats); | ||||
|     if (commonStandardFormats.isEmpty) { | ||||
|       return null; // Incompatible; no "enhanced detection" | ||||
|     } | ||||
|     for (String pattern in commonStandardFormats) { | ||||
|       if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) { | ||||
|         return matchMode | ||||
|             ? internalVersion | ||||
|             : null; // Enhanced detection says no change | ||||
|       } | ||||
|     } | ||||
|     return matchMode | ||||
|         ? null | ||||
|         : realVersion; // Enhanced detection says something changed | ||||
|   } | ||||
|  | ||||
|   Future<void> loadApps() async { | ||||
|     while (loadingApps) { | ||||
|       await Future.delayed(const Duration(microseconds: 1)); | ||||
| @@ -571,7 +606,21 @@ class AppsProvider with ChangeNotifier { | ||||
|     List<App> newApps = (await getAppsDir()) | ||||
|         .listSync() | ||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||
|         .map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync()))) | ||||
|         .map((e) { | ||||
|           try { | ||||
|             return App.fromJson(jsonDecode(File(e.path).readAsStringSync())); | ||||
|           } catch (err) { | ||||
|             if (err is FormatException) { | ||||
|               logs.add('Corrupt JSON when loading App (will be ignored): $e'); | ||||
|               e.renameSync('${e.path}.corrupt'); | ||||
|               return App( | ||||
|                   '', '', '', '', '', '', [], 0, {}, DateTime.now(), false); | ||||
|             } else { | ||||
|               rethrow; | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         .where((element) => element.id.isNotEmpty) | ||||
|         .toList(); | ||||
|     var idsToDelete = apps.values | ||||
|         .map((e) => e.app.id) | ||||
| @@ -614,20 +663,30 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<void> saveApps(List<App> apps, | ||||
|       {bool attemptToCorrectInstallStatus = true}) async { | ||||
|       {bool attemptToCorrectInstallStatus = true, | ||||
|       bool onlyIfExists = true}) async { | ||||
|     attemptToCorrectInstallStatus = | ||||
|         attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork()); | ||||
|     for (var app in apps) { | ||||
|       AppInfo? info = await getInstalledInfo(app.id); | ||||
|       app.name = info?.name ?? app.name; | ||||
|       if (app.additionalSettings['appName']?.toString().isNotEmpty == true) { | ||||
|         app.name = app.additionalSettings['appName'].toString().trim(); | ||||
|       } | ||||
|       if (attemptToCorrectInstallStatus) { | ||||
|         app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app; | ||||
|       } | ||||
|       File('${(await getAppsDir()).path}/${app.id}.json') | ||||
|           .writeAsStringSync(jsonEncode(app.toJson())); | ||||
|       try { | ||||
|         this.apps.update( | ||||
|             app.id, (value) => AppInMemory(app, value.downloadProgress, info), | ||||
|           ifAbsent: () => AppInMemory(app, null, info)); | ||||
|             ifAbsent: onlyIfExists ? null : () => AppInMemory(app, null, info)); | ||||
|       } catch (e) { | ||||
|         if (e is! ArgumentError || e.name != 'key') { | ||||
|           rethrow; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
| @@ -648,8 +707,11 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async { | ||||
|     var showUninstallOption = | ||||
|         apps.where((a) => a.installedVersion != null).isNotEmpty; | ||||
|     var showUninstallOption = apps | ||||
|         .where((a) => | ||||
|             a.installedVersion != null && | ||||
|             a.additionalSettings['trackOnly'] != true) | ||||
|         .isNotEmpty; | ||||
|     var values = await showDialog( | ||||
|         context: context, | ||||
|         builder: (BuildContext ctx) { | ||||
| @@ -698,6 +760,18 @@ class AppsProvider with ChangeNotifier { | ||||
|     await intent.launch(); | ||||
|   } | ||||
|  | ||||
|   addMissingCategories(SettingsProvider settingsProvider) { | ||||
|     var cats = settingsProvider.categories; | ||||
|     apps.forEach((key, value) { | ||||
|       for (var c in value.app.categories) { | ||||
|         if (!cats.containsKey(c)) { | ||||
|           cats[c] = generateRandomLightColor().value; | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|     settingsProvider.setCategories(cats, appsProvider: this); | ||||
|   } | ||||
|  | ||||
|   Future<App?> checkUpdate(String appId) async { | ||||
|     App? currentApp = apps[appId]!.app; | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
| @@ -777,12 +851,6 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   Future<String> exportApps() async { | ||||
|     Directory? exportDir = Directory('/storage/emulated/0/Download'); | ||||
|     String path = 'Downloads'; // TODO: See if hardcoding this can be avoided | ||||
|     if (!exportDir.existsSync()) { | ||||
|       exportDir = await getExternalStorageDirectory(); | ||||
|       path = exportDir!.path; | ||||
|     } | ||||
|     if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) { | ||||
|       if (await Permission.storage.isDenied) { | ||||
|         await Permission.storage.request(); | ||||
| @@ -791,6 +859,18 @@ class AppsProvider with ChangeNotifier { | ||||
|         throw ObtainiumError(tr('storagePermissionDenied')); | ||||
|       } | ||||
|     } | ||||
|     Directory? exportDir = Directory('/storage/emulated/0/Download'); | ||||
|     String path = 'Downloads'; // TODO: See if hardcoding this can be avoided | ||||
|     var downloadsAccessible = false; | ||||
|     try { | ||||
|       downloadsAccessible = exportDir.existsSync(); | ||||
|     } catch (e) { | ||||
|       logs.add('Error accessing Downloads (will use fallback): $e'); | ||||
|     } | ||||
|     if (!downloadsAccessible) { | ||||
|       exportDir = await getExternalStorageDirectory(); | ||||
|       path = exportDir!.path; | ||||
|     } | ||||
|     File export = File( | ||||
|         '${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json'); | ||||
|     export.writeAsStringSync( | ||||
| @@ -810,7 +890,7 @@ class AppsProvider with ChangeNotifier { | ||||
|         a.installedVersion = apps[a.id]?.app.installedVersion; | ||||
|       } | ||||
|     } | ||||
|     await saveApps(importedApps); | ||||
|     await saveApps(importedApps, onlyIfExists: false); | ||||
|     notifyListeners(); | ||||
|     return importedApps.length; | ||||
|   } | ||||
| @@ -830,7 +910,7 @@ class AppsProvider with ChangeNotifier { | ||||
|       if (apps.containsKey(app.id)) { | ||||
|         errorsMap.addAll({app.id: tr('appAlreadyAdded')}); | ||||
|       } else { | ||||
|         await saveApps([app]); | ||||
|         await saveApps([app], onlyIfExists: false); | ||||
|       } | ||||
|     } | ||||
|     List<List<String>> errors = | ||||
|   | ||||
| @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:obtainium/app_sources/github.dart'; | ||||
| import 'package:obtainium/main.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
|  | ||||
| @@ -139,6 +141,15 @@ class SettingsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   bool get groupByCategory { | ||||
|     return prefs?.getBool('groupByCategory') ?? false; | ||||
|   } | ||||
|  | ||||
|   set groupByCategory(bool show) { | ||||
|     prefs?.setBool('groupByCategory', show); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   String? getSettingString(String settingId) { | ||||
|     return prefs?.getString(settingId); | ||||
|   } | ||||
| @@ -151,7 +162,22 @@ class SettingsProvider with ChangeNotifier { | ||||
|   Map<String, int> get categories => | ||||
|       Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}')); | ||||
|  | ||||
|   set categories(Map<String, int> cats) { | ||||
|   void setCategories(Map<String, int> cats, {AppsProvider? appsProvider}) { | ||||
|     if (appsProvider != null) { | ||||
|       List<App> changedApps = appsProvider.apps.values | ||||
|           .map((a) { | ||||
|             var n1 = a.app.categories.length; | ||||
|             a.app.categories.removeWhere((c) => !cats.keys.contains(c)); | ||||
|             return n1 > a.app.categories.length ? a.app : null; | ||||
|           }) | ||||
|           .where((element) => element != null) | ||||
|           .map((e) => e as App) | ||||
|           .toList(); | ||||
|       if (changedApps.isNotEmpty) { | ||||
|         appsProvider.saveApps(changedApps, | ||||
|             attemptToCorrectInstallStatus: false); | ||||
|       } | ||||
|     } | ||||
|     prefs?.setString('categories', jsonEncode(cats)); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|   | ||||
| @@ -20,6 +20,7 @@ import 'package:obtainium/app_sources/signal.dart'; | ||||
| import 'package:obtainium/app_sources/sourceforge.dart'; | ||||
| import 'package:obtainium/app_sources/steammobile.dart'; | ||||
| import 'package:obtainium/app_sources/telegramapp.dart'; | ||||
| import 'package:obtainium/app_sources/vlc.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
| import 'package:obtainium/custom_errors.dart'; | ||||
| import 'package:obtainium/mass_app_sources/githubstars.dart'; | ||||
| @@ -109,7 +110,6 @@ class App { | ||||
|     // Convert bool style version detection options to dropdown style | ||||
|     if (additionalSettings['noVersionDetection'] == true) { | ||||
|       additionalSettings['versionDetection'] = 'noVersionDetection'; | ||||
|     } | ||||
|       if (additionalSettings['releaseDateAsVersion'] == true) { | ||||
|         additionalSettings['versionDetection'] = 'releaseDateAsVersion'; | ||||
|         additionalSettings.remove('releaseDateAsVersion'); | ||||
| @@ -120,6 +120,7 @@ class App { | ||||
|       if (additionalSettings['releaseDateAsVersion'] != null) { | ||||
|         additionalSettings.remove('releaseDateAsVersion'); | ||||
|       } | ||||
|     } | ||||
|     // Ensure additionalSettings are correctly typed | ||||
|     for (var item in formItems) { | ||||
|       if (additionalSettings[item.key] != null) { | ||||
| @@ -228,6 +229,7 @@ class AppSource { | ||||
|   String? host; | ||||
|   late String name; | ||||
|   bool enforceTrackOnly = false; | ||||
|   bool changeLogIfAnyIsMarkDown = true; | ||||
|  | ||||
|   AppSource() { | ||||
|     name = runtimeType.toString(); | ||||
| @@ -276,7 +278,8 @@ class AppSource { | ||||
|               return regExValidator(value); | ||||
|             } | ||||
|           ]) | ||||
|     ] | ||||
|     ], | ||||
|     [GeneratedFormTextField('appName', label: tr('appName'), required: false)] | ||||
|   ]; | ||||
|  | ||||
|   // Previous 2 variables combined into one at runtime for convenient usage | ||||
| @@ -340,13 +343,15 @@ class SourceProvider { | ||||
|     Codeberg(), | ||||
|     FDroid(), | ||||
|     IzzyOnDroid(), | ||||
|     Mullvad(), | ||||
|     Signal(), | ||||
|     FDroidRepo(), | ||||
|     SourceForge(), | ||||
|     APKMirror(), | ||||
|     FDroidRepo(), | ||||
|     SteamMobile(), | ||||
|     Mullvad(), | ||||
|     Signal(), | ||||
|     VLC(), | ||||
|     // WhatsApp(), // As of 2023-03-20 this is unusable as the version on the webpage is months out of date | ||||
|     TelegramApp(), | ||||
|     SteamMobile(), | ||||
|     NeutronCode(), | ||||
|     HTML() // This should ALWAYS be the last option as they are tried in order | ||||
|   ]; | ||||
| @@ -358,7 +363,7 @@ class SourceProvider { | ||||
|     url = preStandardizeUrl(url); | ||||
|     AppSource? source; | ||||
|     for (var s in sources.where((element) => element.host != null)) { | ||||
|       if (url.contains('://${s.host}')) { | ||||
|       if (RegExp('://(.+\\.)?${s.host}').hasMatch(url)) { | ||||
|         source = s; | ||||
|         break; | ||||
|       } | ||||
| @@ -423,8 +428,10 @@ class SourceProvider { | ||||
|       throw NoAPKError(); | ||||
|     } | ||||
|     String apkVersion = apk.version.replaceAll('/', '-'); | ||||
|     var name = currentApp?.name.trim() ?? | ||||
|         apk.names.name[0].toUpperCase() + apk.names.name.substring(1); | ||||
|     var name = currentApp != null ? currentApp.name.trim() : ''; | ||||
|     name = name.isNotEmpty | ||||
|         ? name | ||||
|         : apk.names.name[0].toUpperCase() + apk.names.name.substring(1); | ||||
|     return App( | ||||
|         currentApp?.id ?? | ||||
|             source.tryInferringAppId(standardUrl, | ||||
| @@ -432,9 +439,7 @@ class SourceProvider { | ||||
|             generateTempID(standardUrl, additionalSettings), | ||||
|         standardUrl, | ||||
|         apk.names.author[0].toUpperCase() + apk.names.author.substring(1), | ||||
|         name.trim().isNotEmpty | ||||
|             ? name | ||||
|             : apk.names.name[0].toUpperCase() + apk.names.name.substring(1), | ||||
|         name, | ||||
|         currentApp?.installedVersion, | ||||
|         apkVersion, | ||||
|         apk.apkUrls, | ||||
|   | ||||
							
								
								
									
										78
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										78
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -235,6 +235,14 @@ packages: | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   flutter_markdown: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: flutter_markdown | ||||
|       sha256: "7b25c10de1fea883f3c4f9b8389506b54053cd00807beab69fd65c8653a2711f" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.6.14" | ||||
|   flutter_plugin_android_lifecycle: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -325,6 +333,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.1" | ||||
|   markdown: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: markdown | ||||
|       sha256: d95a9d12954aafc97f984ca29baaa7690ed4d9ec4140a23ad40580bcdb6c87f5 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "7.0.2" | ||||
|   matcher: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -393,10 +409,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: path_provider | ||||
|       sha256: "04890b994ee89bfa80bf3080bfec40d5a92c5c7a785ebb02c13084a099d2b6f9" | ||||
|       sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.13" | ||||
|     version: "2.0.14" | ||||
|   path_provider_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -409,10 +425,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: path_provider_foundation | ||||
|       sha256: "12eee51abdf4d34c590f043f45073adbb45514a108bd9db4491547a2fd891059" | ||||
|       sha256: "818b2dc38b0f178e0ea3f7cf3b28146faab11375985d815942a68eee11c2d0f7" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.2.0" | ||||
|     version: "2.2.1" | ||||
|   path_provider_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -457,10 +473,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_apple | ||||
|       sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163" | ||||
|       sha256: ee96ac32f5a8e6f80756e25b25b9f8e535816c8e6665a96b6d70681f8c4f7e85 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "9.0.7" | ||||
|     version: "9.0.8" | ||||
|   permission_handler_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -537,58 +553,58 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: shared_preferences | ||||
|       sha256: ee6257848f822b8481691f20c3e6d2bfee2e9eccb2a3d249907fcfb198c55b41 | ||||
|       sha256: "858aaa72d8f61637d64e776aca82e1c67e6d9ee07979123c5d17115031c1b13b" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.18" | ||||
|     version: "2.1.0" | ||||
|   shared_preferences_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shared_preferences_android | ||||
|       sha256: ad423a80fe7b4e48b50d6111b3ea1027af0e959e49d485712e134863d9c1c521 | ||||
|       sha256: "8304d8a1f7d21a429f91dee552792249362b68a331ac5c3c1caf370f658873f6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.17" | ||||
|     version: "2.1.0" | ||||
|   shared_preferences_foundation: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shared_preferences_foundation | ||||
|       sha256: "1e755f8583229f185cfca61b1d80fb2344c9d660e1c69ede5450d8f478fa5310" | ||||
|       sha256: cf2a42fb20148502022861f71698db12d937c7459345a1bdaa88fc91a91b3603 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.5" | ||||
|     version: "2.2.0" | ||||
|   shared_preferences_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shared_preferences_linux | ||||
|       sha256: "3a59ed10890a8409ad0faad7bb2957dab4b92b8fbe553257b05d30ed8af2c707" | ||||
|       sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.5" | ||||
|     version: "2.2.0" | ||||
|   shared_preferences_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shared_preferences_platform_interface | ||||
|       sha256: "824bfd02713e37603b2bdade0842e47d56e7db32b1dcdd1cae533fb88e2913fc" | ||||
|       sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.1" | ||||
|     version: "2.2.0" | ||||
|   shared_preferences_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shared_preferences_web | ||||
|       sha256: "0dc2633f215a3d4aa3184c9b2c5766f4711e4e5a6b256e62aafee41f89f1bfb8" | ||||
|       sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.0.6" | ||||
|     version: "2.1.0" | ||||
|   shared_preferences_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: shared_preferences_windows | ||||
|       sha256: "71bcd669bb9cdb6b39f22c4a7728b6d49e934f6cba73157ffa5a54f1eed67436" | ||||
|       sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.5" | ||||
|     version: "2.2.0" | ||||
|   sky_engine: | ||||
|     dependency: transitive | ||||
|     description: flutter | ||||
| @@ -694,18 +710,18 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_android | ||||
|       sha256: "845530e5e05db5500c1a4c1446785d60cbd8f9bd45e21e7dd643a3273bb4bbd1" | ||||
|       sha256: dd729390aa936bf1bdf5cd1bc7468ff340263f80a2c4f569416507667de8e3c8 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.0.25" | ||||
|     version: "6.0.26" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_ios | ||||
|       sha256: "7ab1e5b646623d6a2537aa59d5d039f90eebef75a7c25e105f6f75de1f7750c3" | ||||
|       sha256: "3dedc66ca3c0bef9e6a93c0999aee102556a450afcc1b7bcfeace7a424927d92" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.1.2" | ||||
|     version: "6.1.3" | ||||
|   url_launcher_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -766,18 +782,18 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: webview_flutter | ||||
|       sha256: b6cd42db3ced5411f3d01599906156885b18e4188f7065a8a351eb84bee347e0 | ||||
|       sha256: "47663d51a9061451aa3880a214ee9a65dcbb933b77bc44388e194279ab3ccaf6" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "4.0.6" | ||||
|     version: "4.0.7" | ||||
|   webview_flutter_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_android | ||||
|       sha256: "34f83c2f0f64c75ad75c77a2ccfc8d2e531afbe8ad41af1fd787d6d33336aa90" | ||||
|       sha256: "9e223788e1954087dac30d813dc151f8e12f09f1139f116ce20b5658893f3627" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.4.3" | ||||
|     version: "3.4.4" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -790,10 +806,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_wkwebview | ||||
|       sha256: ab12479f7a0cf112b9420c36aaf206a1ca47cd60cd42de74a4be2e97a697587b | ||||
|       sha256: d601aba11ad8d4481e17a34a76fa1d30dee92dcbbe2c58b0df3120e9453099c7 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "3.2.1" | ||||
|     version: "3.2.3" | ||||
|   win32: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -819,5 +835,5 @@ packages: | ||||
|     source: hosted | ||||
|     version: "6.2.2" | ||||
| sdks: | ||||
|   dart: ">=2.18.2 <3.0.0" | ||||
|   dart: ">=2.19.0 <3.0.0" | ||||
|   flutter: ">=3.4.0-17.0.pre" | ||||
|   | ||||
| @@ -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.11.11+132 # When changing this, update the tag in main() accordingly | ||||
| version: 0.11.23+145 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
| @@ -59,6 +59,7 @@ dependencies: | ||||
|   sqflite: ^2.2.0+3 | ||||
|   easy_localization: ^3.0.1 | ||||
|   android_intent_plus: ^3.1.5 | ||||
|   flutter_markdown: ^0.6.14 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
| @@ -91,6 +92,7 @@ flutter: | ||||
|   assets: | ||||
|     - assets/translations/ | ||||
|     - assets/graphics/ | ||||
|     - assets/ca/ | ||||
|  | ||||
|   # An image asset can refer to one or more resolution-specific "variants", see | ||||
|   # https://flutter.dev/assets-and-images/#resolution-aware | ||||
|   | ||||
		Reference in New Issue
	
	Block a user