Compare commits

...

44 Commits

Author SHA1 Message Date
4c5b9304c0 Merge pull request #409 from ImranR98/dev
Bugfix for prev. commit
2023-03-31 15:40:18 -04:00
4cfe6af044 Bugfix for prev. commit 2023-03-31 15:39:52 -04:00
3f0c4068dd Merge pull request #408 from ImranR98/dev
Bugfix #405 + general categories bugfixes
2023-03-31 15:37:11 -04:00
7981ca29c5 Bugfix #405 + general categories bugfixes 2023-03-31 15:36:51 -04:00
187efa8fc5 Merge pull request #406 from ImranR98/dev
Fixed Mullvad web scraping (again)
2023-03-31 09:25:50 -04:00
cd27ff7f2d Fixed Mullvad web scraping (again) 2023-03-31 09:24:15 -04:00
6f6a25511b Merge pull request #402 from ImranR98/dev
Added "Group by Category" setting
2023-03-30 23:41:06 -04:00
4e17bbcfd1 Added "Group by Category" setting 2023-03-30 23:40:32 -04:00
814e269d1d Merge pull request #401 from ImranR98/dev
Bugfix: "releaseDateAsVersion" resets to "noVersionDetection"
2023-03-30 17:45:24 -04:00
6b7d962b87 Bugfix: "releaseDateAsVersion" resets to "noVersionDetection"
Also 2 related UI fixes
2023-03-30 17:44:39 -04:00
9fba747802 Version detection improvements, Mullvad web scraping fix and changelog addition, code readability improvements, general tweaks/bugfixes (#400)
1. Apps that don't have "standard" versioning formats now automatically stop using version detection. This will prevent users from having to learn about this feature and enable it manually.
    - For such Apps, the "standard" version detection option is greyed out.
2. The Mullvad Source recently broke due to a slight change in their website design. This is now fixed.
    - Mullvad also now provides an in-app changelog via their official GitHub repo.
3. Code has been refactored for readability (specifically the version detection code and UI code for most screens).
4. Minor UI tweaks and bugfixes.
2023-03-30 17:27:36 -04:00
c7cd35b6a1 Merge pull request #388 from ImranR98/dev
Hide (non-functional) uninstall button for track-only Apps, attempt bugfix for cert error (#375)
2023-03-26 07:39:34 -04:00
a8a3fce33a Attempt to fix bug (#375) 2023-03-26 07:37:35 -04:00
3a38cedcf5 Bugfix: Don't show uninstall option for track-only apps 2023-03-25 22:55:40 -04:00
69ccefcf1a Merge pull request #382 from atilluF/main
Update it.json
2023-03-25 00:56:52 -04:00
d3932f317d Merge pull request #384 from ImranR98/dev
Bugfixes: "Return After Delete" (#359) and No Redirect if Navigating During App Addition
2023-03-25 00:56:16 -04:00
895deeead5 Increment version 2023-03-25 00:54:34 -04:00
4c04af3868 Bugfix: App add doesn't redirect if nav away during add 2023-03-25 00:54:12 -04:00
07c490bb0e Fixed "return after delete" bug (#359) 2023-03-25 00:47:26 -04:00
a081d553bb Update it.json 2023-03-24 20:11:03 +01:00
3bc5837999 Merge pull request #380 from ImranR98/dev
Bugfix: Infinite load on corrupt App JSON (#378)
2023-03-22 22:41:59 -04:00
9fbe524818 Bugfix: Infinite load on corrupt App JSON (#378) 2023-03-22 22:36:04 -04:00
c21a9d7292 Merge pull request #373 from ImranR98/dev
Rearranged Sources + Added (Non-Activated) WhatsApp Source (Website Currently Provides the Wrong Version)
2023-03-20 15:20:33 -04:00
9c6068b270 Added WhatsApp (not activated) + rearranged sources 2023-03-20 15:18:55 -04:00
cd86d6112b Merge pull request #371 from ImranR98/dev
Render Changelog as MarkDown (#369) (for some Sources) + VLC as a Source (#367)
2023-03-19 13:54:59 -04:00
1112c79c14 Increment version 2023-03-19 13:53:40 -04:00
08555bac75 Added VLC as a Source 2023-03-19 13:50:14 -04:00
6db31e2b24 Support for normal text changelogs (by Source) 2023-03-19 12:52:34 -04:00
48d2532323 Links in changelog openable 2023-03-19 12:49:43 -04:00
f1fc43a3e7 Don't show 'Changes' button if it doesn't do anything 2023-03-19 12:44:17 -04:00
280827d8ec Changelog now rendered as MarkDown 2023-03-19 12:38:57 -04:00
05ee0f9c48 Merge pull request #366 from ImranR98/dev
Open changelog inside App for supported Sources (#82)
2023-03-18 23:54:08 -04:00
ef06ae289e Open changelog inside App for supported Sources (#82) 2023-03-18 23:53:42 -04:00
bd0e322465 Updated README sources section 2023-03-18 23:20:04 -04:00
a93a2411fa Merge pull request #365 from ImranR98/dev
Added 2 Sources: Neutron Code #287, Telegram #290 (Official App, not Channels as Sources) + Tiny UI Bugfix
2023-03-18 23:12:05 -04:00
26e6eef72e Increment version 2023-03-18 23:10:14 -04:00
e49a6e311b Added Telegram App as a Source 2023-03-18 23:09:11 -04:00
53d3397651 Remove download notification when download fails 2023-03-18 22:58:37 -04:00
fe540f5e61 Added Neutron Code as a Source 2023-03-18 22:54:58 -04:00
234374224b Merge pull request #364 from ImranR98/dev
Made download start more responsive (#361, #327)  + very minor form UI spacing improvement
2023-03-17 23:10:59 -04:00
83390f648a Increment version, update packages 2023-03-17 23:10:18 -04:00
1143b6a546 Better form UI spacing 2023-03-17 23:03:38 -04:00
0f3e029312 Bugfix for previous commit 2023-03-17 22:49:12 -04:00
c0120f4e40 Made download start more responsive (#361, #327) 2023-03-17 16:48:06 -04:00
35 changed files with 2416 additions and 1951 deletions

View File

@ -19,6 +19,9 @@ Currently supported App sources:
- Third Party F-Droid Repos - Third Party F-Droid Repos
- Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo` - Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo`
- [Steam](https://store.steampowered.com/mobile) - [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) - "HTML" (Fallback)
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked) - Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)

View 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-----

View File

@ -220,6 +220,7 @@
"importFromURLsInFile": "Importieren von URLs aus Datei ( z.B. OPML)", "importFromURLsInFile": "Importieren von URLs aus Datei ( z.B. OPML)",
"versionDetection": "Versionserkennung", "versionDetection": "Versionserkennung",
"standardVersionDetection": "Standardversionserkennung", "standardVersionDetection": "Standardversionserkennung",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "App entfernen?", "one": "App entfernen?",
"other": "App entfernen?" "other": "App entfernen?"

View File

@ -220,6 +220,7 @@
"importFromURLsInFile": "Import from URLs in File (like OPML)", "importFromURLsInFile": "Import from URLs in File (like OPML)",
"versionDetection": "Version Detection", "versionDetection": "Version Detection",
"standardVersionDetection": "Standard version detection", "standardVersionDetection": "Standard version detection",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "Remove App?", "one": "Remove App?",
"other": "Remove Apps?" "other": "Remove Apps?"

View File

@ -220,6 +220,7 @@
"importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)", "importFromURLsInFile": "وارد کردن از آدرس های اینترنتی موجود در فایل (مانند OPML)",
"versionDetection": "تشخیص نسخه", "versionDetection": "تشخیص نسخه",
"standardVersionDetection": "تشخیص نسخه استاندارد", "standardVersionDetection": "تشخیص نسخه استاندارد",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "برنامه حذف شود؟", "one": "برنامه حذف شود؟",
"other": "برنامه ها حذف شوند؟" "other": "برنامه ها حذف شوند؟"

View File

@ -220,6 +220,7 @@
"importFromURLsInFile": "Importer à partir d'URL dans un fichier (comme OPML)", "importFromURLsInFile": "Importer à partir d'URL dans un fichier (comme OPML)",
"versionDetection": "Détection des versions", "versionDetection": "Détection des versions",
"standardVersionDetection": "Détection de version standard", "standardVersionDetection": "Détection de version standard",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "Supprimer l'application ?", "one": "Supprimer l'application ?",
"other": "Supprimer les applications ?" "other": "Supprimer les applications ?"

View File

@ -219,6 +219,7 @@
"importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)", "importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)",
"versionDetection": "Verzió érzékelés", "versionDetection": "Verzió érzékelés",
"standardVersionDetection": "Alapért. verzió érzékelés", "standardVersionDetection": "Alapért. verzió érzékelés",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "Eltávolítja az alkalmazást?", "one": "Eltávolítja az alkalmazást?",
"other": "Eltávolítja az alkalmazást?" "other": "Eltávolítja az alkalmazást?"

View File

@ -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.", "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à", "changes": "Novità",
"releaseDate": "Data di rilascio", "releaseDate": "Data di rilascio",
"importFromURLsInFile": "Import from URLs in File (like OPML)", "importFromURLsInFile": "Importa da URL in file (come OPML)",
"versionDetection": "Version Detection", "versionDetection": "Rilevamento di versione",
"standardVersionDetection": "Standard version detection", "standardVersionDetection": "Rilevamento di versione standard",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "Rimuovere l'App?", "one": "Rimuovere l'App?",
"other": "Rimuovere le App?" "other": "Rimuovere le App?"

View File

@ -220,6 +220,7 @@
"importFromURLsInFile": "ファイルOPMLなど内のURLからインポート", "importFromURLsInFile": "ファイルOPMLなど内のURLからインポート",
"versionDetection": "バージョン検出", "versionDetection": "バージョン検出",
"standardVersionDetection": "標準のバージョン検出", "standardVersionDetection": "標準のバージョン検出",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "アプリを削除しますか?", "one": "アプリを削除しますか?",
"other": "アプリを削除しますか?" "other": "アプリを削除しますか?"

View File

@ -220,6 +220,7 @@
"importFromURLsInFile": "Import from URLs in File (like OPML)", "importFromURLsInFile": "Import from URLs in File (like OPML)",
"versionDetection": "Version Detection", "versionDetection": "Version Detection",
"standardVersionDetection": "Standard version detection", "standardVersionDetection": "Standard version detection",
"groupByCategory": "Group by Category",
"removeAppQuestion": { "removeAppQuestion": {
"one": "删除应用?", "one": "删除应用?",
"other": "删除应用?" "other": "删除应用?"

View File

@ -118,9 +118,11 @@ class Codeberg extends AppSource {
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
var changeLog = targetRelease['body'].toString();
return APKDetails(version, targetRelease['apkUrls'] as List<String>, return APKDetails(version, targetRelease['apkUrls'] as List<String>,
getAppNames(standardUrl), getAppNames(standardUrl),
releaseDate: releaseDate); releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -27,9 +27,6 @@ class FDroid extends AppSource {
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
String? tryInferringAppId(String standardUrl, String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) { {Map<String, dynamic> additionalSettings = const {}}) {

View File

@ -160,9 +160,11 @@ class GitHub extends AppSource {
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
var changeLog = targetRelease['body'].toString();
return APKDetails(version, targetRelease['apkUrls'] as List<String>, return APKDetails(version, targetRelease['apkUrls'] as List<String>,
getAppNames(standardUrl), getAppNames(standardUrl),
releaseDate: releaseDate); releaseDate: releaseDate,
changeLog: changeLog.isEmpty ? null : changeLog);
} else { } else {
rateLimitErrorCheck(res); rateLimitErrorCheck(res);
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);

View File

@ -10,9 +10,6 @@ class HTML extends AppSource {
return url; return url;
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,

View File

@ -18,9 +18,6 @@ class IzzyOnDroid extends AppSource {
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
String? tryInferringAppId(String standardUrl, String? tryInferringAppId(String standardUrl,
{Map<String, dynamic> additionalSettings = const {}}) { {Map<String, dynamic> additionalSettings = const {}}) {

View File

@ -1,5 +1,6 @@
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -29,19 +30,37 @@ class Mullvad extends AppSource {
) async { ) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android')); Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var version = parse(res.body) var versions = parse(res.body)
.querySelector('p.subtitle.is-6') .querySelectorAll('p')
?.querySelector('a') .map((e) => e.innerHtml)
?.attributes['href'] .where((p) => p.contains('Latest version: '))
?.split('/') .map((e) {
.last; var match = RegExp('[0-9]+(\\.[0-9]+)*').firstMatch(e);
if (version == null) { if (match == null) {
return '';
} else {
return e.substring(match.start, match.end);
}
})
.where((element) => element.isNotEmpty)
.toList();
if (versions.isEmpty) {
throw NoVersionError(); throw NoVersionError();
} }
String? changeLog;
try {
changeLog = (await GitHub().getLatestAPKDetails(
'https://github.com/mullvad/mullvadvpn-app',
{'fallbackToOlderReleases': true}))
.changeLog;
} catch (e) {
// Ignore
}
return APKDetails( return APKDetails(
version, versions[0],
['https://mullvad.net/download/app/apk/latest'], ['https://mullvad.net/download/app/apk/latest'],
AppNames(name, 'Mullvad-VPN')); AppNames(name, 'Mullvad-VPN'),
changeLog: changeLog);
} else { } else {
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);
} }

View File

@ -0,0 +1,111 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class NeutronCode extends AppSource {
NeutronCode() {
host = 'neutroncode.com';
}
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/downloads/file/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw InvalidURLError(name);
}
return url.substring(0, match.end);
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => standardUrl;
String monthNameToNumberString(String s) {
switch (s.toLowerCase()) {
case 'january':
return '01';
case 'february':
return '02';
case 'march':
return '03';
case 'april':
return '04';
case 'may':
return '05';
case 'june':
return '06';
case 'july':
return '07';
case 'august':
return '08';
case 'september':
return '09';
case 'october':
return '10';
case 'november':
return '11';
case 'december':
return '12';
default:
throw ArgumentError('Invalid month name: $s');
}
}
customDateParse(String dateString) {
List<String> parts = dateString.split(' ');
if (parts.length != 3) {
return null;
}
String result = '';
for (var s in parts.reversed) {
try {
try {
int.parse(s);
result += '$s-';
} catch (e) {
result += '${monthNameToNumberString(s)}-';
}
} catch (e) {
return null;
}
}
return result.substring(0, result.length - 1);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var http = parse(res.body);
var name = http.querySelector('.pd-title')?.innerHtml;
var filename = http.querySelector('.pd-filename .pd-float')?.innerHtml;
if (filename == null) {
throw NoReleasesError();
}
var version =
http.querySelector('.pd-version-txt')?.nextElementSibling?.innerHtml;
if (version == null) {
throw NoVersionError();
}
String? apkUrl = 'https://$host/download/$filename';
var dateStringOriginal =
http.querySelector('.pd-date-txt')?.nextElementSibling?.innerHtml;
var dateString = dateStringOriginal != null
? (customDateParse(dateStringOriginal))
: null;
var changeLogElements = http.querySelectorAll('.pd-fdesc p');
return APKDetails(version, [apkUrl],
AppNames(runtimeType.toString(), name ?? standardUrl.split('/').last),
releaseDate: dateString != null ? DateTime.parse(dateString) : null,
changeLog: changeLogElements.isNotEmpty
? changeLogElements.last.innerHtml
: null);
} else {
throw getObtainiumHttpError(res);
}
}
}

View File

@ -13,9 +13,6 @@ class Signal extends AppSource {
return 'https://$host'; return 'https://$host';
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,

View File

@ -18,9 +18,6 @@ class SourceForge extends AppSource {
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,

View File

@ -24,9 +24,6 @@ class SteamMobile extends AppSource {
return 'https://$host'; return 'https://$host';
} }
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override @override
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, String standardUrl,

View File

@ -0,0 +1,40 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';
class TelegramApp extends AppSource {
TelegramApp() {
host = 'telegram.org';
name = 'Telegram ${tr('app')}';
}
@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://t.me/s/TAndroidAPK'));
if (res.statusCode == 200) {
var http = parse(res.body);
var messages =
http.querySelectorAll('.tgme_widget_message_text.js-message_text');
var version = messages.isNotEmpty
? messages.last.innerHtml.split('\n').first.trim().split(' ').first
: null;
if (version == null) {
throw NoVersionError();
}
String? apkUrl = 'https://telegram.org/dl/android/apk';
return APKDetails(version, [apkUrl], AppNames('Telegram', 'Telegram'));
} else {
throw getObtainiumHttpError(res);
}
}
}

62
lib/app_sources/vlc.dart Normal file
View 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);
}
}
}

View 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);
}
}
}

View File

@ -48,6 +48,7 @@ class GeneratedFormTextField extends GeneratedFormItem {
class GeneratedFormDropdown extends GeneratedFormItem { class GeneratedFormDropdown extends GeneratedFormItem {
late List<MapEntry<String, String>>? opts; late List<MapEntry<String, String>>? opts;
List<String>? disabledOptKeys;
GeneratedFormDropdown( GeneratedFormDropdown(
String key, String key,
@ -55,6 +56,7 @@ class GeneratedFormDropdown extends GeneratedFormItem {
String label = 'Input', String label = 'Input',
List<Widget> belowWidgets = const [], List<Widget> belowWidgets = const [],
String defaultValue = '', String defaultValue = '',
this.disabledOptKeys,
List<String? Function(String? value)> additionalValidators = const [], List<String? Function(String? value)> additionalValidators = const [],
}) : super(key, }) : super(key,
label: label, label: label,
@ -225,10 +227,15 @@ class _GeneratedFormState extends State<GeneratedForm> {
return DropdownButtonFormField( return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label), decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key], value: values[formItem.key],
items: formItem.opts! items: formItem.opts!.map((e2) {
.map((e2) => var enabled =
DropdownMenuItem(value: e2.key, child: Text(e2.value))) formItem.disabledOptKeys?.contains(e2.key) != true;
.toList(), return DropdownMenuItem(
value: e2.key,
enabled: enabled,
child: Opacity(
opacity: enabled ? 1 : 0.5, child: Text(e2.value)));
}).toList(),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
values[formItem.key] = value ?? formItem.opts!.first.key; values[formItem.key] = value ?? formItem.opts!.first.key;
@ -460,10 +467,9 @@ class _GeneratedFormState extends State<GeneratedForm> {
if (rowInputs.key > 0) { if (rowInputs.key > 0) {
rows.add([ rows.add([
SizedBox( SizedBox(
height: widget.items[rowInputs.key][0] is GeneratedFormSwitch && height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
widget.items[rowInputs.key - 1][0] is! GeneratedFormSwitch ? 8
? 25 : 25,
: 8,
) )
]); ]);
} }
@ -477,6 +483,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
rowItems.add(Expanded( rowItems.add(Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
rowInput.value, rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets ...widget.items[rowInputs.key][rowInput.key].belowWidgets

View File

@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports // ignore: implementation_imports
import 'package:easy_localization/src/localization.dart'; import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.11.8'; const String currentVersion = '0.11.19';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES '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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); 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(); await EasyLocalization.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
@ -210,7 +218,7 @@ class _ObtainiumState extends State<Obtainium> {
{'includePrereleases': true}, {'includePrereleases': true},
null, null,
false) false)
]); ], onlyIfExists: false);
} }
if (!supportedLocales if (!supportedLocales
.map((e) => e.languageCode) .map((e) => e.languageCode)

View File

@ -33,10 +33,10 @@ class _AddAppPageState extends State<AddAppPage> {
bool additionalSettingsValid = true; bool additionalSettingsValid = true;
List<String> pickedCategories = []; List<String> pickedCategories = [];
int searchnum = 0; int searchnum = 0;
SourceProvider sourceProvider = SourceProvider();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>(); AppsProvider appsProvider = context.read<AppsProvider>();
bool doingSomething = gettingAppInfo || searching; bool doingSomething = gettingAppInfo || searching;
@ -64,65 +64,56 @@ class _AddAppPageState extends State<AddAppPage> {
} }
} }
getTrackOnlyConfirmationIfNeeded(bool userPickedTrackOnly) async {
return (!((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
// ignore: use_build_context_synchronously
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly
? tr('source')
: tr('app')
]),
items: const [],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
}) ==
null));
}
getReleaseDateAsVersionConfirmationIfNeeded(
bool userPickedTrackOnly) async {
return (!(additionalSettings['versionDetection'] ==
'releaseDateAsVersion' &&
// ignore: use_build_context_synchronously
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('releaseDateAsVersion'),
items: const [],
message: tr('releaseDateAsVersionExplanation'),
);
}) ==
null));
}
addApp({bool resetUserInputAfter = false}) async { addApp({bool resetUserInputAfter = false}) async {
setState(() { setState(() {
gettingAppInfo = true; gettingAppInfo = true;
}); });
var settingsProvider = context.read<SettingsProvider>(); try {
() async { var settingsProvider = context.read<SettingsProvider>();
var userPickedTrackOnly = additionalSettings['trackOnly'] == true; var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
var cont = true; App? app;
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) &&
// ignore: use_build_context_synchronously (await getReleaseDateAsVersionConfirmationIfNeeded(
await showDialog( userPickedTrackOnly))) {
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('xIsTrackOnly', args: [
pickedSource!.enforceTrackOnly
? tr('source')
: tr('app')
]),
items: const [],
message:
'${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
);
}) ==
null) {
cont = false;
}
if (additionalSettings['versionDetection'] == 'releaseDateAsVersion' &&
// ignore: use_build_context_synchronously
await showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('releaseDateAsVersion'),
items: const [],
message: tr('releaseDateAsVersionExplanation'),
);
}) ==
null) {
cont = false;
}
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();
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp( app = await sourceProvider.getApp(
pickedSource!, userInput, additionalSettings, pickedSource!, userInput, additionalSettings,
trackOnlyOverride: trackOnly); trackOnlyOverride: trackOnly);
if (!trackOnly) { if (!trackOnly) {
@ -149,28 +140,233 @@ class _AddAppPageState extends State<AddAppPage> {
app.installedVersion = app.latestVersion; app.installedVersion = app.latestVersion;
} }
app.categories = pickedCategories; app.categories = pickedCategories;
await appsProvider.saveApps([app]); await appsProvider.saveApps([app], onlyIfExists: false);
return app;
} }
}()
.then((app) {
if (app != null) { if (app != null) {
Navigator.push(context, Navigator.push(globalNavigatorKey.currentContext ?? context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); MaterialPageRoute(builder: (context) => AppPage(appId: app!.id)));
} }
}).catchError((e) { } catch (e) {
showError(e, context); showError(e, context);
}).whenComplete(() { } finally {
setState(() { setState(() {
gettingAppInfo = false; gettingAppInfo = false;
if (resetUserInputAfter) { if (resetUserInputAfter) {
changeUserInput('', false, true); changeUserInput('', false, true);
} }
}); });
}); }
} }
Widget getUrlInputRow() => Row(
children: [
Expanded(
child: GeneratedForm(
key: Key(searchnum.toString()),
items: [
[
GeneratedFormTextField('appSourceURL',
label: tr('appSourceURL'),
defaultValue: userInput,
additionalValidators: [
(value) {
try {
sourceProvider
.getSource(value ?? '')
.standardizeURL(
preStandardizeUrl(value ?? ''));
} catch (e) {
return e is String
? e
: e is ObtainiumError
? e.toString()
: tr('error');
}
return null;
}
])
]
],
onValueChanges: (values, valid, isBuilding) {
changeUserInput(
values['appSourceURL']!, valid, isBuilding);
})),
const SizedBox(
width: 16,
),
gettingAppInfo
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: doingSomething ||
pickedSource == null ||
(pickedSource!.combinedAppSpecificSettingFormItems
.isNotEmpty &&
!additionalSettingsValid)
? null
: () {
HapticFeedback.selectionClick();
addApp();
},
child: Text(tr('add')))
],
);
runSearch() async {
setState(() {
searching = true;
});
try {
var results = await 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
? []
// 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;
Widget getSearchBarRow() => Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormTextField('searchSomeSources',
label: tr('searchSomeSourcesLabel'), required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty && valid && !isBuilding) {
setState(() {
searchQuery = values['searchSomeSources']!.trim();
});
}
}),
),
const SizedBox(
width: 16,
),
ElevatedButton(
onPressed: searchQuery.isEmpty || doingSomething
? null
: () {
runSearch();
},
child: Text(tr('search')))
],
);
Widget getAdditionalOptsCol() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(
height: 64,
),
Text(
tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]),
style: TextStyle(color: Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
GeneratedForm(
key: Key(pickedSource.runtimeType.toString()),
items: pickedSource!.combinedAppSpecificSettingFormItems,
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
additionalSettings = values;
additionalSettingsValid = valid;
});
}
}),
Column(
children: [
const SizedBox(
height: 16,
),
CategoryEditorSelector(
alignment: WrapAlignment.start,
onSelected: (categories) {
pickedCategories = categories;
}),
],
),
],
);
Widget getSourcesListWidget() => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 48,
),
Text(
tr('supportedSourcesBelow'),
),
const SizedBox(
height: 8,
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: e.host != null
? () {
launchUrlString('https://${e.host}',
mode: LaunchMode.externalApplication);
}
: null,
child: Text(
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: TextStyle(
decoration: e.host != null
? TextDecoration.underline
: TextDecoration.none,
fontStyle: FontStyle.italic),
)))
.toList()
]));
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -181,230 +377,16 @@ class _AddAppPageState extends State<AddAppPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( getUrlInputRow(),
children: [ if (shouldShowSearchBar())
Expanded(
child: GeneratedForm(
key: Key(searchnum.toString()),
items: [
[
GeneratedFormTextField('appSourceURL',
label: tr('appSourceURL'),
defaultValue: userInput,
additionalValidators: [
(value) {
try {
sourceProvider
.getSource(value ?? '')
.standardizeURL(
preStandardizeUrl(
value ?? ''));
} catch (e) {
return e is String
? e
: e is ObtainiumError
? e.toString()
: tr('error');
}
return null;
}
])
]
],
onValueChanges: (values, valid, isBuilding) {
changeUserInput(values['appSourceURL']!,
valid, isBuilding);
})),
const SizedBox(
width: 16,
),
gettingAppInfo
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: doingSomething ||
pickedSource == null ||
(pickedSource!
.combinedAppSpecificSettingFormItems
.isNotEmpty &&
!additionalSettingsValid)
? null
: addApp,
child: Text(tr('add')))
],
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
const SizedBox( const SizedBox(
height: 16, height: 16,
), ),
if (sourceProvider.sources if (shouldShowSearchBar()) getSearchBarRow(),
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormTextField(
'searchSomeSources',
label: tr('searchSomeSourcesLabel'),
required: false),
]
],
onValueChanges: (values, valid, isBuilding) {
if (values.isNotEmpty &&
valid &&
!isBuilding) {
setState(() {
searchQuery =
values['searchSomeSources']!.trim();
});
}
}),
),
const SizedBox(
width: 16,
),
ElevatedButton(
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;
});
});
},
child: Text(tr('search')))
],
),
if (pickedSource != null) if (pickedSource != null)
Column( getAdditionalOptsCol()
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(
height: 64,
),
Text(
tr('additionalOptsFor',
args: [pickedSource?.name ?? tr('source')]),
style: TextStyle(
color:
Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
GeneratedForm(
key: Key(pickedSource.runtimeType.toString()),
items: pickedSource!
.combinedAppSpecificSettingFormItems,
onValueChanges: (values, valid, isBuilding) {
if (!isBuilding) {
setState(() {
additionalSettings = values;
additionalSettingsValid = valid;
});
}
}),
Column(
children: [
const SizedBox(
height: 16,
),
CategoryEditorSelector(
alignment: WrapAlignment.start,
onSelected: (categories) {
pickedCategories = categories;
}),
],
),
],
)
else else
Expanded( getSourcesListWidget(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 48,
),
Text(
tr('supportedSourcesBelow'),
),
const SizedBox(
height: 8,
),
...sourceProvider.sources
.map((e) => GestureDetector(
onTap: e.host != null
? () {
launchUrlString(
'https://${e.host}',
mode: LaunchMode
.externalApplication);
}
: null,
child: Text(
'${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
style: TextStyle(
decoration: e.host != null
? TextDecoration.underline
: TextDecoration.none,
fontStyle: FontStyle.italic),
)))
.toList()
])),
const SizedBox( const SizedBox(
height: 8, height: 8,
), ),

View File

@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart'; import 'package:obtainium/main.dart';
@ -34,406 +35,414 @@ class _AppPageState extends State<AppPage> {
}); });
} }
bool areDownloadsRunning = appsProvider.areDownloadsRunning();
var sourceProvider = SourceProvider(); var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null; 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; prevApp = app;
getUpdate(app.app.id); getUpdate(app.app.id);
} }
var trackOnly = app?.app.additionalSettings['trackOnly'] == true; var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
var infoColumn = Column( bool isVersionDetectionStandard =
mainAxisAlignment: MainAxisAlignment.center, app?.app.additionalSettings['versionDetection'] ==
crossAxisAlignment: CrossAxisAlignment.stretch, 'standardVersionDetection';
children: [
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
tr('latestVersionX', args: [app?.app.latestVersion ?? tr('unknown')]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'${tr('installedVersionX', args: [
app?.app.installedVersion ?? tr('none')
])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
tr('app')
])}' : ''}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 32,
),
Text(
tr('lastUpdateCheckX', args: [
app?.app.lastUpdateCheck == null
? tr('never')
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
]),
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 48,
),
CategoryEditorSelector(
alignment: WrapAlignment.center,
preselected:
app?.app.categories != null ? app!.app.categories.toSet() : {},
onSelected: (categories) {
if (app != null) {
app.app.categories = categories;
appsProvider.saveApps([app.app]);
}
}),
],
);
var fullInfoColumn = Column( getInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 125), GestureDetector(
app?.installedInfo != null onTap: () {
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [ if (app?.app.url != null) {
Image.memory( launchUrlString(app?.app.url ?? '',
app!.installedInfo!.icon!, mode: LaunchMode.externalApplication);
height: 150, }
gaplessPlayback: true, },
) child: Text(
]) app?.app.url ?? '',
: Container(), textAlign: TextAlign.center,
const SizedBox( style: const TextStyle(
height: 25, decoration: TextDecoration.underline,
), fontStyle: FontStyle.italic,
Text( fontSize: 12),
app?.installedInfo?.name ?? app?.app.name ?? tr('app'), )),
textAlign: TextAlign.center, const SizedBox(
style: Theme.of(context).textTheme.displayLarge, height: 32,
), ),
Text( Text(
tr('byX', args: [app?.app.author ?? tr('unknown')]), tr('latestVersionX',
textAlign: TextAlign.center, args: [app?.app.latestVersion ?? tr('unknown')]),
style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center,
), style: Theme.of(context).textTheme.bodyLarge,
const SizedBox( ),
height: 8, Text(
), '${tr('installedVersionX', args: [
Text( app?.app.installedVersion ?? tr('none')
app?.app.id ?? '', ])}${trackOnly ? ' ${tr('estimateInBrackets')}\n\n${tr('xIsTrackOnly', args: [
textAlign: TextAlign.center, tr('app')
style: Theme.of(context).textTheme.labelSmall, ])}' : ''}',
), textAlign: TextAlign.center,
app?.app.releaseDate == null style: Theme.of(context).textTheme.bodyLarge,
? const SizedBox.shrink() ),
: Text( if (app?.app.installedVersion != null &&
app!.app.releaseDate.toString(), !isVersionDetectionStandard)
textAlign: TextAlign.center, Column(
style: Theme.of(context).textTheme.labelSmall, children: [
const SizedBox(
height: 4,
),
Text(
tr('noVersionDetection'),
style: Theme.of(context).textTheme.labelSmall,
)
],
), ),
const SizedBox( const SizedBox(
height: 32, height: 32,
), ),
infoColumn, Text(
const SizedBox(height: 150) tr('lastUpdateCheckX', args: [
], app?.app.lastUpdateCheck == null
); ? tr('never')
: '\n${app?.app.lastUpdateCheck?.toLocal()}'
]),
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
),
const SizedBox(
height: 48,
),
CategoryEditorSelector(
alignment: WrapAlignment.center,
preselected: app?.app.categories != null
? app!.app.categories.toSet()
: {},
onSelected: (categories) {
if (app != null) {
app.app.categories = categories;
appsProvider.saveApps([app.app]);
}
}),
],
);
getFullInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 125),
app?.installedInfo != null
? Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Image.memory(
app!.installedInfo!.icon!,
height: 150,
gaplessPlayback: true,
)
])
: Container(),
const SizedBox(
height: 25,
),
Text(
app?.installedInfo?.name ?? app?.app.name ?? tr('app'),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
tr('byX', args: [app?.app.author ?? tr('unknown')]),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 8,
),
Text(
app?.app.id ?? '',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
app?.app.releaseDate == null
? const SizedBox.shrink()
: Text(
app!.app.releaseDate.toString(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelSmall,
),
const SizedBox(
height: 32,
),
getInfoColumn(),
const SizedBox(height: 150)
],
);
getAppWebView() => app != null
? WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Theme.of(context).colorScheme.background)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onWebResourceError: (WebResourceError error) {
if (error.isForMainFrame == true) {
showError(
ObtainiumError(error.description, unexpected: true),
context);
}
},
),
)
..loadRequest(Uri.parse(app.app.url)))
: 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]);
}
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: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != null &&
app?.app.installedVersion != app?.app.latestVersion &&
!isVersionDetectionStandard &&
!trackOnly)
IconButton(
onPressed: app?.downloadProgress != null
? null
: showMarkUpdatedDialog,
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)),
if (source != null &&
source.combinedAppSpecificSettingFormItems.isNotEmpty)
IconButton(
onPressed: app?.downloadProgress != null
? null
: () async {
var values =
await showAdditionalOptionsDialog();
handleAdditionalOptionChanges(values);
},
tooltip: tr('additionalOptions'),
icon: const Icon(Icons.edit)),
if (app != null && app.installedInfo != null)
IconButton(
onPressed: () {
appsProvider.openAppSettings(app.app.id);
},
icon: const Icon(Icons.settings),
tooltip: tr('settings'),
),
if (app != null && settingsProvider.showAppWebpage)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
content: getInfoColumn(),
title: Text(
'${app.app.name} ${tr('byX', args: [
app.app.author
])}'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('continue')))
],
);
});
},
icon: const Icon(Icons.more_horiz),
tooltip: tr('more')),
const SizedBox(width: 16.0),
Expanded(child: getInstallOrUpdateButton()),
const SizedBox(width: 16.0),
Expanded(
child: TextButton(
onPressed: app?.downloadProgress != null
? null
: () {
appsProvider.removeAppsWithModal(
context, [app!.app]).then((value) {
if (value == true) {
Navigator.of(context).pop();
}
});
},
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: Text(tr('remove')),
)),
])),
if (app?.downloadProgress != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: LinearProgressIndicator(
value: app!.downloadProgress! / 100))
],
));
return Scaffold( return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null, appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator( body: RefreshIndicator(
child: settingsProvider.showAppWebpage child: settingsProvider.showAppWebpage
? app != null ? getAppWebView()
? WebViewWidget( : CustomScrollView(
controller: WebViewController() slivers: [
..setJavaScriptMode(JavaScriptMode.unrestricted) SliverToBoxAdapter(
..setBackgroundColor( child: Column(children: [getFullInfoColumn()])),
Theme.of(context).colorScheme.background) ],
..setJavaScriptMode(JavaScriptMode.unrestricted) ),
..setNavigationDelegate( onRefresh: () async {
NavigationDelegate( if (app != null) {
onWebResourceError: (WebResourceError error) { getUpdate(app.app.id);
if (error.isForMainFrame == true) { }
showError( }),
ObtainiumError(error.description, bottomSheet: getBottomSheetMenu());
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);
}
}),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.additionalSettings['versionDetection'] !=
'standardVersionDetection' &&
!trackOnly &&
app?.app.installedVersion != null &&
app?.app.installedVersion != app?.app.latestVersion)
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')))
],
);
});
},
tooltip: tr('markUpdated'),
icon: const Icon(Icons.done)),
if (source != null &&
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);
});
}
});
},
tooltip: tr('additionalOptions'),
icon: const Icon(Icons.edit)),
if (app != null && app.installedInfo != null)
IconButton(
onPressed: () {
appsProvider.openAppSettings(app.app.id);
},
icon: const Icon(Icons.settings),
tooltip: tr('settings'),
),
if (app != null && settingsProvider.showAppWebpage)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
content: infoColumn,
title: Text(
'${app.app.name} ${tr('byX', args: [
app.app.author
])}'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(tr('continue')))
],
);
});
},
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')))),
const SizedBox(width: 16.0),
Expanded(
child: TextButton(
onPressed: app?.downloadProgress != null
? null
: () {
appsProvider.removeAppsWithModal(
context, [app!.app]).then((value) {
if (value == true) {
Navigator.of(context).pop();
}
});
},
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: Text(tr('remove')),
)),
])),
if (app?.downloadProgress != null)
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: LinearProgressIndicator(
value: app!.downloadProgress! / 100))
],
)),
);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var settingsProvider = context.read<SettingsProvider>(); var settingsProvider = context.read<SettingsProvider>();
var outlineButtonStyle = ButtonStyle( var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all( shape: MaterialStateProperty.all(
StadiumBorder( 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( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -120,18 +308,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: appsProvider.apps.isEmpty || onPressed: appsProvider.apps.isEmpty ||
importInProgress importInProgress
? null ? null
: () { : runObtainiumExport,
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
showError(
tr('exportedTo', args: [path]),
context);
}).catchError((e) {
showError(e, context);
});
},
child: Text(tr('obtainiumExport')))), child: Text(tr('obtainiumExport')))),
const SizedBox( const SizedBox(
width: 16, width: 16,
@ -141,59 +318,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
style: outlineButtonStyle, style: outlineButtonStyle,
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : 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;
}
}
});
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;
});
});
},
child: Text(tr('obtainiumImport')))) child: Text(tr('obtainiumImport'))))
], ],
), ),
@ -216,49 +341,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
height: 32, height: 32,
), ),
TextButton( TextButton(
onPressed: importInProgress onPressed:
? null importInProgress ? null : urlListImport,
: () {
urlListImport();
},
child: Text( child: Text(
tr('importFromURLList'), tr('importFromURLList'),
)), )),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: importInProgress onPressed:
? null importInProgress ? null : 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'));
}
});
},
child: Text( child: Text(
tr('importFromURLsInFile'), tr('importFromURLsInFile'),
)), )),
@ -275,106 +366,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
() async { runSourceSearch(source);
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;
});
});
}, },
child: Text( child: Text(
tr('searchX', args: [source.name]))) tr('searchX', args: [source.name])))
@ -390,93 +382,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress onPressed: importInProgress
? null ? null
: () { : () {
() async { runMassSourceImport(source);
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;
});
});
}, },
child: Text( child: Text(
tr('importX', args: [source.name]))) tr('importX', args: [source.name])))

View File

@ -6,6 +6,7 @@ import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart'; import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_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( const Divider(
height: 16, height: 16,
), ),
@ -432,6 +445,7 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
var appsProvider = context.watch<AppsProvider>();
storedValues = settingsProvider.categories.map((key, value) => MapEntry( storedValues = settingsProvider.categories.map((key, value) => MapEntry(
key, key,
MapEntry(value, MapEntry(value,
@ -455,8 +469,9 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
if (!isBuilding) { if (!isBuilding) {
storedValues = storedValues =
values['categories'] as Map<String, MapEntry<int, bool>>; values['categories'] as Map<String, MapEntry<int, bool>>;
settingsProvider.categories = settingsProvider.setCategories(
storedValues.map((key, value) => MapEntry(key, value.key)); storedValues.map((key, value) => MapEntry(key, value.key)),
appsProvider: appsProvider);
if (widget.onSelected != null) { if (widget.onSelected != null) {
widget.onSelected!(storedValues.keys widget.onSelected!(storedValues.keys
.where((k) => storedValues[k]!.value) .where((k) => storedValues[k]!.value)

View File

@ -73,6 +73,18 @@ List<String> generateStandardVersionRegExStrings() {
List<String> standardVersionRegExStrings = List<String> standardVersionRegExStrings =
generateStandardVersionRegExStrings(); 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 { class AppsProvider with ChangeNotifier {
// In memory App state (should always be kept in sync with local storage versions) // In memory App state (should always be kept in sync with local storage versions)
Map<String, AppInMemory> apps = {}; Map<String, AppInMemory> apps = {};
@ -145,56 +157,68 @@ class AppsProvider with ChangeNotifier {
} }
Future<DownloadedApk> downloadApp(App app, BuildContext? context) async { Future<DownloadedApk> downloadApp(App app, BuildContext? context) async {
var fileName =
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
String downloadUrl = await SourceProvider()
.getSource(app.url)
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
NotificationsProvider? notificationsProvider = NotificationsProvider? notificationsProvider =
context?.read<NotificationsProvider>(); context?.read<NotificationsProvider>();
var notif = DownloadNotification(app.name, 100); var notifId = DownloadNotification(app.name, 0).id;
notificationsProvider?.cancel(notif.id); if (apps[app.id] != null) {
int? prevProg; apps[app.id]!.downloadProgress = 0;
File downloadedFile = notifyListeners();
await downloadFile(downloadUrl, fileName, (double? progress) { }
int? prog = progress?.ceil(); try {
var fileName =
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
String downloadUrl = await SourceProvider()
.getSource(app.url)
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
var notif = DownloadNotification(app.name, 100);
notificationsProvider?.cancel(notif.id);
int? prevProg;
File downloadedFile =
await downloadFile(downloadUrl, fileName, (double? progress) {
int? prog = progress?.ceil();
if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress;
notifyListeners();
}
notif = DownloadNotification(app.name, prog ?? 100);
if (prog != null && prevProg != prog) {
notificationsProvider?.notify(notif);
}
prevProg = prog;
});
// Delete older versions of the APK if any
for (var file in downloadedFile.parent.listSync()) {
var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') &&
fn != fileName) {
file.delete();
}
}
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
// The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
if (apps[app.id] != null && !SourceProvider().isTempId(app)) {
throw IDChangedError();
}
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
}
}
return DownloadedApk(app.id, downloadedFile);
} finally {
notificationsProvider?.cancel(notifId);
if (apps[app.id] != null) { if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = progress; apps[app.id]!.downloadProgress = null;
notifyListeners(); notifyListeners();
} }
notif = DownloadNotification(app.name, prog ?? 100);
if (prog != null && prevProg != prog) {
notificationsProvider?.notify(notif);
}
prevProg = prog;
});
notificationsProvider?.cancel(notif.id);
// Delete older versions of the APK if any
for (var file in downloadedFile.parent.listSync()) {
var fn = file.path.split('/').last;
if (fn.startsWith('${app.id}-') &&
fn.endsWith('.apk') &&
fn != fileName) {
file.delete();
}
} }
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
// The former case should be handled (give the App its real ID), the latter is a security issue
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
if (app.id != newInfo.packageName) {
if (apps[app.id] != null && !SourceProvider().isTempId(app)) {
throw IDChangedError();
}
var originalAppId = app.id;
app.id = newInfo.packageName;
downloadedFile = downloadedFile.renameSync(
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
if (apps[originalAppId] != null) {
await removeApps([originalAppId]);
await saveApps([app]);
}
}
return DownloadedApk(app.id, downloadedFile);
} }
bool areDownloadsRunning() => apps.values bool areDownloadsRunning() => apps.values
@ -460,94 +484,117 @@ class AppsProvider with ChangeNotifier {
return res; return res;
} }
// If the App says it is installed but installedInfo is null, set it to not installed bool isVersionDetectionPossible(AppInMemory? app) {
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently return app?.app.additionalSettings['trackOnly'] != true &&
// If that fails, just set it to the actual version string (all we can do at that point) app?.app.additionalSettings['versionDetection'] !=
// Don't save changes, just return the object if changes were made (else null) '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) { App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
var modded = false; var modded = false;
var trackOnly = app.additionalSettings['trackOnly'] == true; var trackOnly = app.additionalSettings['trackOnly'] == true;
var noVersionDetection = app.additionalSettings['versionDetection'] != var noVersionDetection = app.additionalSettings['versionDetection'] !=
'standardVersionDetection'; 'standardVersionDetection';
// FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL
if (installedInfo == null && app.installedVersion != null && !trackOnly) { 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; app.installedVersion = null;
modded = true; modded = true;
} else if (installedInfo?.versionName != null && } else if (installedInfo?.versionName != null &&
app.installedVersion == 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; app.installedVersion = installedInfo!.versionName;
modded = true; 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 && installedInfo!.versionName != app.installedVersion &&
!noVersionDetection) { !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!); installedInfo.versionName!, app.installedVersion!);
if (correctedInstalledVersion != null) { if (correctedInstalledVersion?.key == false) {
app.installedVersion = correctedInstalledVersion; app.installedVersion = correctedInstalledVersion!.value;
modded = true; modded = true;
} }
} }
// THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS
if (app.installedVersion != null && if (app.installedVersion != null &&
app.installedVersion != app.latestVersion && app.installedVersion != app.latestVersion &&
!noVersionDetection) { !noVersionDetection) {
app.installedVersion = reconcileRealAndInternalVersions( // App's reported installed and latest versions don't match (and it uses standard version detection)
app.installedVersion!, app.latestVersion, // If they share a standard format, make sure the App's reported installed version uses that format
matchMode: true) ?? var correctedInstalledVersion =
app.installedVersion; 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; 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; return modded ? app : null;
} }
String? reconcileRealAndInternalVersions( MapEntry<bool, String>? reconcileVersionDifferences(
String realVersion, String internalVersion, String templateVersion, String comparisonVersion) {
{bool matchMode = false}) { // Returns null if the versions don't share a common standard format
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is) // Returns <true, comparisonVersion> if they share a common format and are equal
// 2. If both have a "standard" format under which they are equal, return null (leave as is) // Returns <false, templateVersion> if they share a common format but are not equal
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally) // templateVersion must fully match a standard format, while comparisonVersion can have a substring match
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly var templateVersionFormats =
// Matchmode to be used when comparing internal install version and internal latest version findStandardFormatsForVersion(templateVersion, true);
var comparisonVersionFormats =
bool doStringsMatchUnderRegEx( findStandardFormatsForVersion(comparisonVersion, false);
String pattern, String value1, String value2) {
var r = RegExp(pattern);
var m1 = r.firstMatch(value1);
var m2 = r.firstMatch(value2);
return m1 != null && m2 != null
? value1.substring(m1.start, m1.end) ==
value2.substring(m2.start, m2.end)
: 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 = var commonStandardFormats =
realStandardVersionFormats.intersection(internalStandardVersionFormats); templateVersionFormats.intersection(comparisonVersionFormats);
if (commonStandardFormats.isEmpty) { if (commonStandardFormats.isEmpty) {
return null; // Incompatible; no "enhanced detection" return null;
} }
for (String pattern in commonStandardFormats) { for (String pattern in commonStandardFormats) {
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) { if (doStringsMatchUnderRegEx(
return matchMode pattern, comparisonVersion, templateVersion)) {
? internalVersion return MapEntry(true, comparisonVersion);
: null; // Enhanced detection says no change
} }
} }
return matchMode return MapEntry(false, templateVersion);
? null }
: realVersion; // Enhanced detection says something changed
bool doStringsMatchUnderRegEx(String pattern, String value1, String value2) {
var r = RegExp(pattern);
var m1 = r.firstMatch(value1);
var m2 = r.firstMatch(value2);
return m1 != null && m2 != null
? value1.substring(m1.start, m1.end) ==
value2.substring(m2.start, m2.end)
: false;
} }
Future<void> loadApps() async { Future<void> loadApps() async {
@ -559,7 +606,21 @@ class AppsProvider with ChangeNotifier {
List<App> newApps = (await getAppsDir()) List<App> newApps = (await getAppsDir())
.listSync() .listSync()
.where((item) => item.path.toLowerCase().endsWith('.json')) .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(); .toList();
var idsToDelete = apps.values var idsToDelete = apps.values
.map((e) => e.app.id) .map((e) => e.app.id)
@ -602,7 +663,8 @@ class AppsProvider with ChangeNotifier {
} }
Future<void> saveApps(List<App> apps, Future<void> saveApps(List<App> apps,
{bool attemptToCorrectInstallStatus = true}) async { {bool attemptToCorrectInstallStatus = true,
bool onlyIfExists = true}) async {
attemptToCorrectInstallStatus = attemptToCorrectInstallStatus =
attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork()); attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork());
for (var app in apps) { for (var app in apps) {
@ -613,9 +675,15 @@ class AppsProvider with ChangeNotifier {
} }
File('${(await getAppsDir()).path}/${app.id}.json') File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson())); .writeAsStringSync(jsonEncode(app.toJson()));
this.apps.update( try {
app.id, (value) => AppInMemory(app, value.downloadProgress, info), this.apps.update(
ifAbsent: () => AppInMemory(app, null, info)); app.id, (value) => AppInMemory(app, value.downloadProgress, info),
ifAbsent: onlyIfExists ? null : () => AppInMemory(app, null, info));
} catch (e) {
if (e is! ArgumentError || e.name != 'key') {
rethrow;
}
}
} }
notifyListeners(); notifyListeners();
} }
@ -636,8 +704,11 @@ class AppsProvider with ChangeNotifier {
} }
Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async { Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async {
var showUninstallOption = var showUninstallOption = apps
apps.where((a) => a.installedVersion != null).isNotEmpty; .where((a) =>
a.installedVersion != null &&
a.additionalSettings['trackOnly'] != true)
.isNotEmpty;
var values = await showDialog( var values = await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
@ -686,6 +757,18 @@ class AppsProvider with ChangeNotifier {
await intent.launch(); 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 { Future<App?> checkUpdate(String appId) async {
App? currentApp = apps[appId]!.app; App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
@ -798,7 +881,7 @@ class AppsProvider with ChangeNotifier {
a.installedVersion = apps[a.id]?.app.installedVersion; a.installedVersion = apps[a.id]?.app.installedVersion;
} }
} }
await saveApps(importedApps); await saveApps(importedApps, onlyIfExists: false);
notifyListeners(); notifyListeners();
return importedApps.length; return importedApps.length;
} }
@ -818,7 +901,7 @@ class AppsProvider with ChangeNotifier {
if (apps.containsKey(app.id)) { if (apps.containsKey(app.id)) {
errorsMap.addAll({app.id: tr('appAlreadyAdded')}); errorsMap.addAll({app.id: tr('appAlreadyAdded')});
} else { } else {
await saveApps([app]); await saveApps([app], onlyIfExists: false);
} }
} }
List<List<String>> errors = List<List<String>> errors =

View File

@ -7,6 +7,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/main.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:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -139,6 +141,15 @@ class SettingsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
bool get groupByCategory {
return prefs?.getBool('groupByCategory') ?? false;
}
set groupByCategory(bool show) {
prefs?.setBool('groupByCategory', show);
notifyListeners();
}
String? getSettingString(String settingId) { String? getSettingString(String settingId) {
return prefs?.getString(settingId); return prefs?.getString(settingId);
} }
@ -151,7 +162,22 @@ class SettingsProvider with ChangeNotifier {
Map<String, int> get categories => Map<String, int> get categories =>
Map<String, int>.from(jsonDecode(prefs?.getString('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)); prefs?.setString('categories', jsonEncode(cats));
notifyListeners(); notifyListeners();
} }

View File

@ -15,9 +15,12 @@ import 'package:obtainium/app_sources/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart'; import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/html.dart'; import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/app_sources/mullvad.dart'; import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/neutroncode.dart';
import 'package:obtainium/app_sources/signal.dart'; import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart'; import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/app_sources/steammobile.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/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart';
@ -34,8 +37,10 @@ class APKDetails {
late List<String> apkUrls; late List<String> apkUrls;
late AppNames names; late AppNames names;
late DateTime? releaseDate; late DateTime? releaseDate;
late String? changeLog;
APKDetails(this.version, this.apkUrls, this.names, {this.releaseDate}); APKDetails(this.version, this.apkUrls, this.names,
{this.releaseDate, this.changeLog});
} }
class App { class App {
@ -52,6 +57,7 @@ class App {
bool pinned = false; bool pinned = false;
List<String> categories; List<String> categories;
late DateTime? releaseDate; late DateTime? releaseDate;
late String? changeLog;
App( App(
this.id, this.id,
this.url, this.url,
@ -65,7 +71,8 @@ class App {
this.lastUpdateCheck, this.lastUpdateCheck,
this.pinned, this.pinned,
{this.categories = const [], {this.categories = const [],
this.releaseDate}); this.releaseDate,
this.changeLog});
@override @override
String toString() { String toString() {
@ -103,16 +110,16 @@ class App {
// Convert bool style version detection options to dropdown style // Convert bool style version detection options to dropdown style
if (additionalSettings['noVersionDetection'] == true) { if (additionalSettings['noVersionDetection'] == true) {
additionalSettings['versionDetection'] = 'noVersionDetection'; additionalSettings['versionDetection'] = 'noVersionDetection';
} if (additionalSettings['releaseDateAsVersion'] == true) {
if (additionalSettings['releaseDateAsVersion'] == true) { additionalSettings['versionDetection'] = 'releaseDateAsVersion';
additionalSettings['versionDetection'] = 'releaseDateAsVersion'; additionalSettings.remove('releaseDateAsVersion');
additionalSettings.remove('releaseDateAsVersion'); }
} if (additionalSettings['noVersionDetection'] != null) {
if (additionalSettings['noVersionDetection'] != null) { additionalSettings.remove('noVersionDetection');
additionalSettings.remove('noVersionDetection'); }
} if (additionalSettings['releaseDateAsVersion'] != null) {
if (additionalSettings['releaseDateAsVersion'] != null) { additionalSettings.remove('releaseDateAsVersion');
additionalSettings.remove('releaseDateAsVersion'); }
} }
// Ensure additionalSettings are correctly typed // Ensure additionalSettings are correctly typed
for (var item in formItems) { for (var item in formItems) {
@ -128,34 +135,35 @@ class App {
preferredApkIndex = 0; preferredApkIndex = 0;
} }
return App( return App(
json['id'] as String, json['id'] as String,
json['url'] as String, json['url'] as String,
json['author'] as String, json['author'] as String,
json['name'] as String, json['name'] as String,
json['installedVersion'] == null json['installedVersion'] == null
? null ? null
: json['installedVersion'] as String, : json['installedVersion'] as String,
json['latestVersion'] as String, json['latestVersion'] as String,
json['apkUrls'] == null json['apkUrls'] == null
? [] ? []
: List<String>.from(jsonDecode(json['apkUrls'])), : List<String>.from(jsonDecode(json['apkUrls'])),
preferredApkIndex, preferredApkIndex,
additionalSettings, additionalSettings,
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false, json['pinned'] ?? false,
categories: json['categories'] != null categories: json['categories'] != null
? (json['categories'] as List<dynamic>) ? (json['categories'] as List<dynamic>)
.map((e) => e.toString()) .map((e) => e.toString())
.toList() .toList()
: json['category'] != null : json['category'] != null
? [json['category'] as String] ? [json['category'] as String]
: [], : [],
releaseDate: json['releaseDate'] == null releaseDate: json['releaseDate'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['releaseDate']), : DateTime.fromMicrosecondsSinceEpoch(json['releaseDate']),
); changeLog:
json['changeLog'] == null ? null : json['changeLog'] as String);
} }
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -171,7 +179,8 @@ class App {
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned, 'pinned': pinned,
'categories': categories, 'categories': categories,
'releaseDate': releaseDate?.microsecondsSinceEpoch 'releaseDate': releaseDate?.microsecondsSinceEpoch,
'changeLog': changeLog
}; };
} }
@ -220,6 +229,7 @@ class AppSource {
String? host; String? host;
late String name; late String name;
bool enforceTrackOnly = false; bool enforceTrackOnly = false;
bool changeLogIfAnyIsMarkDown = true;
AppSource() { AppSource() {
name = runtimeType.toString(); name = runtimeType.toString();
@ -332,12 +342,16 @@ class SourceProvider {
Codeberg(), Codeberg(),
FDroid(), FDroid(),
IzzyOnDroid(), IzzyOnDroid(),
Mullvad(), FDroidRepo(),
Signal(),
SourceForge(), SourceForge(),
APKMirror(), APKMirror(),
FDroidRepo(), 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(), SteamMobile(),
NeutronCode(),
HTML() // This should ALWAYS be the last option as they are tried in order HTML() // This should ALWAYS be the last option as they are tried in order
]; ];
@ -433,7 +447,8 @@ class SourceProvider {
DateTime.now(), DateTime.now(),
currentApp?.pinned ?? false, currentApp?.pinned ?? false,
categories: currentApp?.categories ?? const [], categories: currentApp?.categories ?? const [],
releaseDate: apk.releaseDate); releaseDate: apk.releaseDate,
changeLog: apk.changeLog);
} }
// Returns errors in [results, errors] instead of throwing them // Returns errors in [results, errors] instead of throwing them

View File

@ -181,10 +181,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: d090ae03df98b0247b82e5928f44d1b959867049d18d73635e2e0bc3f49542b9 sha256: d8e9ca7e5d1983365c277f12c21b4362df6cf659c99af146ad4d04eb33033013
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.5" version: "5.2.6"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -235,6 +235,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -325,6 +333,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
markdown:
dependency: transitive
description:
name: markdown
sha256: b3c60dee8c2af50ad0e6e90cceba98e47718a6ee0a7a6772c77846a0cc21f78b
url: "https://pub.dev"
source: hosted
version: "7.0.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -393,34 +409,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "04890b994ee89bfa80bf3080bfec40d5a92c5c7a785ebb02c13084a099d2b6f9" sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.13" version: "2.0.14"
path_provider_android: path_provider_android:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "7623b7d4be0f0f7d9a8b5ee6879fc13e4522d4c875ab86801dee4af32b54b83e" sha256: "019f18c9c10ae370b08dce1f3e3b73bc9f58e7f087bb5e921f06529438ac0ae7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.23" version: "2.0.24"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: eec003594f19fe2456ea965ae36b3fc967bc5005f508890aafe31fa75e41d972 sha256: "818b2dc38b0f178e0ea3f7cf3b28146faab11375985d815942a68eee11c2d0f7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.2.1"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
name: path_provider_linux name: path_provider_linux
sha256: "525ad5e07622d19447ad740b1ed5070031f7a5437f44355ae915ff56e986429a" sha256: "2ae08f2216225427e64ad224a24354221c2c7907e448e6e0e8b57b1eb9f10ad1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.9" version: "2.1.10"
path_provider_platform_interface: path_provider_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -433,10 +449,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_windows name: path_provider_windows
sha256: "642ddf65fde5404f83267e8459ddb4556316d3ee6d511ed193357e25caa3632d" sha256: f53720498d5a543f9607db4b0e997c4b5438884de25b0f73098cc2671a51b130
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@ -457,10 +473,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163" sha256: ee96ac32f5a8e6f80756e25b25b9f8e535816c8e6665a96b6d70681f8c4f7e85
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.7" version: "9.0.8"
permission_handler_platform_interface: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -537,58 +553,58 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: ee6257848f822b8481691f20c3e6d2bfee2e9eccb2a3d249907fcfb198c55b41 sha256: "78528fd87d0d08ffd3e69551173c026e8eacc7b7079c82eb6a77413957b7e394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.18" version: "2.0.20"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: a51a4f9375097f94df1c6e0a49c0374440d31ab026b59d58a7e7660675879db4 sha256: ad423a80fe7b4e48b50d6111b3ea1027af0e959e49d485712e134863d9c1c521
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.16" version: "2.0.17"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_foundation name: shared_preferences_foundation
sha256: "6b84fdf06b32bb336f972d373cd38b63734f3461ba56ac2ba01b56d052796259" sha256: "1e755f8583229f185cfca61b1d80fb2344c9d660e1c69ede5450d8f478fa5310"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
shared_preferences_linux: shared_preferences_linux:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
sha256: d7fb71e6e20cd3dfffcc823a28da3539b392e53ed5fc5c2b90b55fdaa8a7e8fa sha256: "3a59ed10890a8409ad0faad7bb2957dab4b92b8fbe553257b05d30ed8af2c707"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "824bfd02713e37603b2bdade0842e47d56e7db32b1dcdd1cae533fb88e2913fc" sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.2.0"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_web name: shared_preferences_web
sha256: "6737b757e49ba93de2a233df229d0b6a87728cea1684da828cbc718b65dcf9d7" sha256: "0dc2633f215a3d4aa3184c9b2c5766f4711e4e5a6b256e62aafee41f89f1bfb8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.5" version: "2.0.6"
shared_preferences_windows: shared_preferences_windows:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_windows name: shared_preferences_windows
sha256: bd014168e8484837c39ef21065b78f305810ceabc1d4f90be6e3b392ce81b46d sha256: "71bcd669bb9cdb6b39f22c4a7728b6d49e934f6cba73157ffa5a54f1eed67436"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -606,18 +622,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: sqflite name: sqflite
sha256: "851d5040552cf911f4cabda08d003eca76b27da3ed0002978272e27c8fbf8ecc" sha256: "500d6fec583d2c021f2d25a056d96654f910662c64f836cd2063167b8f1fa758"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.5" version: "2.2.6"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:
name: sqflite_common name: sqflite_common
sha256: bfd6973aaeeb93475bc0d875ac9aefddf7965ef22ce09790eb963992ffc5183f sha256: "963dad8c4aa2f814ce7d2d5b1da2f36f31bd1a439d8f27e3dc189bb9d26bc684"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2+2" version: "2.4.3"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -694,34 +710,34 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "1f4d9ebe86f333c15d318f81dcdc08b01d45da44af74552608455ebdc08d9732" sha256: dd729390aa936bf1bdf5cd1bc7468ff340263f80a2c4f569416507667de8e3c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.24" version: "6.0.26"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_ios name: url_launcher_ios
sha256: c9cd648d2f7ab56968e049d4e9116f96a85517f1dd806b96a86ea1018a3a82e5 sha256: "3dedc66ca3c0bef9e6a93c0999aee102556a450afcc1b7bcfeace7a424927d92"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.1" version: "6.1.3"
url_launcher_linux: url_launcher_linux:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
sha256: e29039160ab3730e42f3d811dc2a6d5f2864b90a70fb765ea60144b03307f682 sha256: "206fb8334a700ef7754d6a9ed119e7349bc830448098f21a69bf1b4ed038cabc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.4"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "2dddb3291a57b074dade66b5e07e64401dd2487caefd4e9e2f467138d8c7eb06" sha256: "0ef2b4f97942a16523e51256b799e9aa1843da6c60c55eefbfa9dbc2dcb8331a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.4"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -734,18 +750,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "574cfbe2390666003c3a1d129bdc4574aaa6728f0c00a4829a81c316de69dd9b" sha256: "81fe91b6c4f84f222d186a9d23c73157dc4c8e1c71489c4d08be1ad3b228f1aa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.15" version: "2.0.16"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_windows name: url_launcher_windows
sha256: "97c9067950a0d09cbd93e2e3f0383d1403989362b97102fbf446473a48079a4b" sha256: a83ba3607a507758669cfafb03f9de09bf6e6280c14d9b9cb18f013e406dcacd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.4" version: "3.0.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:
@ -766,34 +782,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: webview_flutter name: webview_flutter
sha256: b6cd42db3ced5411f3d01599906156885b18e4188f7065a8a351eb84bee347e0 sha256: "47663d51a9061451aa3880a214ee9a65dcbb933b77bc44388e194279ab3ccaf6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.6" version: "4.0.7"
webview_flutter_android: webview_flutter_android:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_android name: webview_flutter_android
sha256: "5dd3f32b5c2d8f4bf9d05a349e4a65fa718eb137f396f336c3893d558a58fe84" sha256: "34f83c2f0f64c75ad75c77a2ccfc8d2e531afbe8ad41af1fd787d6d33336aa90"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.2" version: "3.4.3"
webview_flutter_platform_interface: webview_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
sha256: df6472164b3f4eaf3280422227f361dc8424b106726b7f21d79a8656ba53f71f sha256: "1939c39e2150fb4d30fd3cc59a891a49fed9935db53007df633ed83581b6117b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "2.1.0"
webview_flutter_wkwebview: webview_flutter_wkwebview:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_wkwebview name: webview_flutter_wkwebview
sha256: "87b6353b40e04f04d5f895a484ad6d92d682d9cce4d2d5b32d2d8aca2448d46e" sha256: d601aba11ad8d4481e17a34a76fa1d30dee92dcbbe2c58b0df3120e9453099c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.0" version: "3.2.3"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@ -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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.11.8+129 # When changing this, update the tag in main() accordingly version: 0.11.19+141 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'
@ -59,6 +59,7 @@ dependencies:
sqflite: ^2.2.0+3 sqflite: ^2.2.0+3
easy_localization: ^3.0.1 easy_localization: ^3.0.1
android_intent_plus: ^3.1.5 android_intent_plus: ^3.1.5
flutter_markdown: ^0.6.14
dev_dependencies: dev_dependencies:
@ -91,6 +92,7 @@ flutter:
assets: assets:
- assets/translations/ - assets/translations/
- assets/graphics/ - assets/graphics/
- assets/ca/
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware # https://flutter.dev/assets-and-images/#resolution-aware