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
- Any URLs ending with `/fdroid/<word>`, where `<word>` can be anything - most often `repo`
- [Steam](https://store.steampowered.com/mobile)
- [Telegram App](https://telegram.org)
- [VLC](https://www.videolan.org/vlc/download-android.html)
- [Neutron Code](https://neutroncode.com)
- "HTML" (Fallback)
- Any other URL that returns an HTML page with links to APK files (if multiple, the last file alphabetically is picked)

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)",
"versionDetection": "Versionserkennung",
"standardVersionDetection": "Standardversionserkennung",
"groupByCategory": "Group by Category",
"removeAppQuestion": {
"one": "App entfernen?",
"other": "App entfernen?"

View File

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

View File

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

View File

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

View File

@ -219,6 +219,7 @@
"importFromURLsInFile": "Importálás fájlban található URL-ből (mint pl. OPML)",
"versionDetection": "Verzió érzékelés",
"standardVersionDetection": "Alapért. verzió érzékelés",
"groupByCategory": "Group by Category",
"removeAppQuestion": {
"one": "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.",
"changes": "Novità",
"releaseDate": "Data di rilascio",
"importFromURLsInFile": "Import from URLs in File (like OPML)",
"versionDetection": "Version Detection",
"standardVersionDetection": "Standard version detection",
"importFromURLsInFile": "Importa da URL in file (come OPML)",
"versionDetection": "Rilevamento di versione",
"standardVersionDetection": "Rilevamento di versione standard",
"groupByCategory": "Group by Category",
"removeAppQuestion": {
"one": "Rimuovere l'App?",
"other": "Rimuovere le App?"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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';
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) => null;
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,

View File

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

View File

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

View File

@ -21,7 +21,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
// ignore: implementation_imports
import 'package:easy_localization/src/localization.dart';
const String currentVersion = '0.11.8';
const String currentVersion = '0.11.19';
const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -147,6 +147,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
try {
ByteData data =
await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem');
SecurityContext.defaultContext
.setTrustedCertificatesBytes(data.buffer.asUint8List());
} catch (e) {
// Already added, do nothing (see #375)
}
await EasyLocalization.ensureInitialized();
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
SystemChrome.setSystemUIOverlayStyle(
@ -210,7 +218,7 @@ class _ObtainiumState extends State<Obtainium> {
{'includePrereleases': true},
null,
false)
]);
], onlyIfExists: false);
}
if (!supportedLocales
.map((e) => e.languageCode)

View File

@ -33,10 +33,10 @@ class _AddAppPageState extends State<AddAppPage> {
bool additionalSettingsValid = true;
List<String> pickedCategories = [];
int searchnum = 0;
SourceProvider sourceProvider = SourceProvider();
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
bool doingSomething = gettingAppInfo || searching;
@ -64,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 {
setState(() {
gettingAppInfo = true;
});
var settingsProvider = context.read<SettingsProvider>();
() async {
try {
var settingsProvider = context.read<SettingsProvider>();
var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
var cont = true;
if ((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) {
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();
App? app;
if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) &&
(await getReleaseDateAsVersionConfirmationIfNeeded(
userPickedTrackOnly))) {
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
App app = await sourceProvider.getApp(
app = await sourceProvider.getApp(
pickedSource!, userInput, additionalSettings,
trackOnlyOverride: trackOnly);
if (!trackOnly) {
@ -149,28 +140,233 @@ class _AddAppPageState extends State<AddAppPage> {
app.installedVersion = app.latestVersion;
}
app.categories = pickedCategories;
await appsProvider.saveApps([app]);
return app;
await appsProvider.saveApps([app], onlyIfExists: false);
}
}()
.then((app) {
if (app != null) {
Navigator.push(context,
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
Navigator.push(globalNavigatorKey.currentContext ?? context,
MaterialPageRoute(builder: (context) => AppPage(appId: app!.id)));
}
}).catchError((e) {
} catch (e) {
showError(e, context);
}).whenComplete(() {
} finally {
setState(() {
gettingAppInfo = false;
if (resetUserInputAfter) {
changeUserInput('', false, true);
}
});
});
}
}
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(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
@ -181,230 +377,16 @@ class _AddAppPageState extends State<AddAppPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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
: addApp,
child: Text(tr('add')))
],
),
if (sourceProvider.sources
.where((e) => e.canSearch)
.isNotEmpty &&
pickedSource == null &&
userInput.isEmpty)
getUrlInputRow(),
if (shouldShowSearchBar())
const SizedBox(
height: 16,
),
if (sourceProvider.sources
.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 (shouldShowSearchBar()) getSearchBarRow(),
if (pickedSource != null)
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;
}),
],
),
],
)
getAdditionalOptsCol()
else
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()
])),
getSourcesListWidget(),
const SizedBox(
height: 8,
),

View File

@ -1,6 +1,7 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
@ -34,406 +35,414 @@ class _AppPageState extends State<AppPage> {
});
}
bool areDownloadsRunning = appsProvider.areDownloadsRunning();
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
if (!areDownloadsRunning && prevApp == null && app != null) {
prevApp = app;
getUpdate(app.app.id);
}
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
var infoColumn = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
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]);
}
}),
],
);
bool isVersionDetectionStandard =
app?.app.additionalSettings['versionDetection'] ==
'standardVersionDetection';
var fullInfoColumn = 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,
getInfoColumn() => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
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,
),
if (app?.app.installedVersion != null &&
!isVersionDetectionStandard)
Column(
children: [
const SizedBox(
height: 4,
),
Text(
tr('noVersionDetection'),
style: Theme.of(context).textTheme.labelSmall,
)
],
),
const SizedBox(
height: 32,
),
infoColumn,
const SizedBox(height: 150)
],
);
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]);
}
}),
],
);
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(
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? 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()
: 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))
],
)),
);
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? getAppWebView()
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()])),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
}),
bottomSheet: getBottomSheetMenu());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
SourceProvider sourceProvider = SourceProvider();
var appsProvider = context.read<AppsProvider>();
var settingsProvider = context.read<SettingsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
@ -101,6 +102,193 @@ class _ImportExportPageState extends State<ImportExportPage> {
});
}
runObtainiumExport() {
HapticFeedback.selectionClick();
appsProvider.exportApps().then((String path) {
showError(tr('exportedTo', args: [path]), context);
}).catchError((e) {
showError(e, context);
});
}
runObtainiumImport() {
HapticFeedback.selectionClick();
FilePicker.platform.pickFiles().then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(result.files.single.path!).readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(tr('invalidInput'));
}
appsProvider.importApps(data).then((value) {
var cats = settingsProvider.categories;
appsProvider.apps.forEach((key, value) {
for (var c in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] = generateRandomLightColor().value;
}
}
});
appsProvider.addMissingCategories(settingsProvider);
showError(tr('importedX', args: [plural('apps', value)]), context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
runUrlImport() {
FilePicker.platform.pickFiles().then((result) {
if (result != null) {
urlListImport(
overrideInitValid: true,
initValue: RegExp('https?://[^"]+')
.allMatches(
File(result.files.single.path!).readAsStringSync())
.map((e) => e.input.substring(e.start, e.end))
.toSet()
.toList()
.where((url) {
try {
sourceProvider.getSource(url);
return true;
} catch (e) {
return false;
}
}).join('\n'));
}
});
}
runSourceSearch(AppSource source) {
() async {
var values = await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX', args: [source.name]),
items: [
[
GeneratedFormTextField('searchQuery',
label: tr('searchQuery'))
]
],
);
});
if (values != null &&
(values['searchQuery'] as String?)?.isNotEmpty == true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source.search(values['searchQuery'] as String);
if (urlsWithDescriptions.isNotEmpty) {
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: urlsWithDescriptions,
selectedByDefault: false,
);
});
if (selectedUrls != null && selectedUrls.isNotEmpty) {
var errors = await appsProvider.addAppsByURL(selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX', args: [plural('app', selectedUrls.length)]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: selectedUrls.length, errors: errors);
});
}
}
} else {
throw ObtainiumError(tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
runMassSourceImport(MassAppUrlSource source) {
() async {
var values = await showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX', args: [source.name]),
items: source.requiredArgs
.map((e) => [GeneratedFormTextField(e, label: e)])
.toList(),
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions = await source.getUrlsWithDescriptions(
values.values.map((e) => e.toString()).toList());
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return UrlSelectionModal(
urlsWithDescriptions: urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors = await appsProvider.addAppsByURL(selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX', args: [plural('app', selectedUrls.length)]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: selectedUrls.length, errors: errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
@ -120,18 +308,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: appsProvider.apps.isEmpty ||
importInProgress
? null
: () {
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
showError(
tr('exportedTo', args: [path]),
context);
}).catchError((e) {
showError(e, context);
});
},
: runObtainiumExport,
child: Text(tr('obtainiumExport')))),
const SizedBox(
width: 16,
@ -141,59 +318,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw ObtainiumError(
tr('invalidInput'));
}
appsProvider
.importApps(data)
.then((value) {
var cats =
settingsProvider.categories;
appsProvider.apps
.forEach((key, value) {
for (var c
in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] =
generateRandomLightColor()
.value;
}
}
});
settingsProvider.categories =
cats;
showError(
tr('importedX', args: [
plural('apps', value)
]),
context);
});
} else {
// User canceled the picker
}
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
: runObtainiumImport,
child: Text(tr('obtainiumImport'))))
],
),
@ -216,49 +341,15 @@ class _ImportExportPageState extends State<ImportExportPage> {
height: 32,
),
TextButton(
onPressed: importInProgress
? null
: () {
urlListImport();
},
onPressed:
importInProgress ? null : urlListImport,
child: Text(
tr('importFromURLList'),
)),
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
FilePicker.platform
.pickFiles()
.then((result) {
if (result != null) {
urlListImport(
overrideInitValid: true,
initValue:
RegExp('https?://[^"]+')
.allMatches(File(result
.files
.single
.path!)
.readAsStringSync())
.map((e) =>
e.input.substring(
e.start, e.end))
.toSet()
.toList()
.where((url) {
try {
sourceProvider
.getSource(url);
return true;
} catch (e) {
return false;
}
}).join('\n'));
}
});
},
onPressed:
importInProgress ? null : runUrlImport,
child: Text(
tr('importFromURLsInFile'),
)),
@ -275,106 +366,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog<
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('searchX',
args: [
source.name
]),
items: [
[
GeneratedFormTextField(
'searchQuery',
label: tr(
'searchQuery'))
]
],
);
});
if (values != null &&
(values['searchQuery']
as String?)
?.isNotEmpty ==
true) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source.search(
values['searchQuery']
as String);
if (urlsWithDescriptions
.isNotEmpty) {
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<
List<
String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
selectedByDefault:
false,
);
});
if (selectedUrls !=
null &&
selectedUrls
.isNotEmpty) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
} else {
throw ObtainiumError(
tr('noResults'));
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
runSourceSearch(source);
},
child: Text(
tr('searchX', args: [source.name])))
@ -390,93 +382,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
onPressed: importInProgress
? null
: () {
() async {
var values = await showDialog<
Map<String,
dynamic>?>(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title: tr('importX',
args: [
source.name
]),
items:
source
.requiredArgs
.map(
(e) => [
GeneratedFormTextField(e,
label: e)
])
.toList(),
);
});
if (values != null) {
setState(() {
importInProgress = true;
});
var urlsWithDescriptions =
await source
.getUrlsWithDescriptions(
values.values
.map((e) =>
e.toString())
.toList());
var selectedUrls =
// ignore: use_build_context_synchronously
await showDialog<
List<String>?>(
context: context,
builder:
(BuildContext
ctx) {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions);
});
if (selectedUrls != null) {
var errors =
await appsProvider
.addAppsByURL(
selectedUrls);
if (errors.isEmpty) {
// ignore: use_build_context_synchronously
showError(
tr('importedX',
args: [
plural(
'app',
selectedUrls
.length)
]),
context);
} else {
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength:
selectedUrls
.length,
errors:
errors);
});
}
}
}
}()
.catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
runMassSourceImport(source);
},
child: Text(
tr('importX', args: [source.name])))

View File

@ -6,6 +6,7 @@ import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
@ -262,6 +263,18 @@ class _SettingsPageState extends State<SettingsPage> {
})
],
),
height16,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(tr('groupByCategory')),
Switch(
value: settingsProvider.groupByCategory,
onChanged: (value) {
settingsProvider.groupByCategory = value;
})
],
),
const Divider(
height: 16,
),
@ -432,6 +445,7 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
@override
Widget build(BuildContext context) {
var settingsProvider = context.watch<SettingsProvider>();
var appsProvider = context.watch<AppsProvider>();
storedValues = settingsProvider.categories.map((key, value) => MapEntry(
key,
MapEntry(value,
@ -455,8 +469,9 @@ class _CategoryEditorSelectorState extends State<CategoryEditorSelector> {
if (!isBuilding) {
storedValues =
values['categories'] as Map<String, MapEntry<int, bool>>;
settingsProvider.categories =
storedValues.map((key, value) => MapEntry(key, value.key));
settingsProvider.setCategories(
storedValues.map((key, value) => MapEntry(key, value.key)),
appsProvider: appsProvider);
if (widget.onSelected != null) {
widget.onSelected!(storedValues.keys
.where((k) => storedValues[k]!.value)

View File

@ -73,6 +73,18 @@ List<String> generateStandardVersionRegExStrings() {
List<String> standardVersionRegExStrings =
generateStandardVersionRegExStrings();
Set<String> findStandardFormatsForVersion(String version, bool strict) {
// If !strict, even a substring match is valid
Set<String> results = {};
for (var pattern in standardVersionRegExStrings) {
if (RegExp('${strict ? '^' : ''}$pattern${strict ? '\$' : ''}')
.hasMatch(version)) {
results.add(pattern);
}
}
return results;
}
class AppsProvider with ChangeNotifier {
// In memory App state (should always be kept in sync with local storage versions)
Map<String, AppInMemory> apps = {};
@ -145,56 +157,68 @@ class AppsProvider with ChangeNotifier {
}
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 =
context?.read<NotificationsProvider>();
var notif = DownloadNotification(app.name, 100);
notificationsProvider?.cancel(notif.id);
int? prevProg;
File downloadedFile =
await downloadFile(downloadUrl, fileName, (double? progress) {
int? prog = progress?.ceil();
var notifId = DownloadNotification(app.name, 0).id;
if (apps[app.id] != null) {
apps[app.id]!.downloadProgress = 0;
notifyListeners();
}
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) {
apps[app.id]!.downloadProgress = progress;
apps[app.id]!.downloadProgress = null;
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
@ -460,94 +484,117 @@ class AppsProvider with ChangeNotifier {
return res;
}
// If the App says it is installed but installedInfo is null, set it to not installed
// If there is any other mismatch between installedInfo and installedVersion, try reconciling them intelligently
// If that fails, just set it to the actual version string (all we can do at that point)
// Don't save changes, just return the object if changes were made (else null)
bool isVersionDetectionPossible(AppInMemory? app) {
return app?.app.additionalSettings['trackOnly'] != true &&
app?.app.additionalSettings['versionDetection'] !=
'releaseDateAsVersion' &&
app?.installedInfo?.versionName != null &&
app?.app.installedVersion != null &&
reconcileVersionDifferences(
app!.installedInfo!.versionName!, app.app.installedVersion!) !=
null;
}
// Given an App and it's on-device info...
// Reconcile unexpected differences between its reported installed version, real installed version, and reported latest version
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
var modded = false;
var trackOnly = app.additionalSettings['trackOnly'] == true;
var noVersionDetection = app.additionalSettings['versionDetection'] !=
'standardVersionDetection';
// FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL
if (installedInfo == null && app.installedVersion != null && !trackOnly) {
// App says it's installed but isn't really (and isn't track only) - set to not installed
app.installedVersion = null;
modded = true;
} else if (installedInfo?.versionName != null &&
app.installedVersion == null) {
// App says it's not installed but really is - set to installed and use real package versionName
app.installedVersion = installedInfo!.versionName;
modded = true;
} else if (installedInfo?.versionName != null &&
}
// SECOND, RECONCILE DIFFERENCES BETWEEN THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE NEITHER IS NULL
if (installedInfo?.versionName != null &&
installedInfo!.versionName != app.installedVersion &&
!noVersionDetection) {
String? correctedInstalledVersion = reconcileRealAndInternalVersions(
// App's reported version and real version don't match (and it uses standard version detection)
// If they share a standard format (and are still different under it), update the reported version accordingly
var correctedInstalledVersion = reconcileVersionDifferences(
installedInfo.versionName!, app.installedVersion!);
if (correctedInstalledVersion != null) {
app.installedVersion = correctedInstalledVersion;
if (correctedInstalledVersion?.key == false) {
app.installedVersion = correctedInstalledVersion!.value;
modded = true;
}
}
// THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS
if (app.installedVersion != null &&
app.installedVersion != app.latestVersion &&
!noVersionDetection) {
app.installedVersion = reconcileRealAndInternalVersions(
app.installedVersion!, app.latestVersion,
matchMode: true) ??
app.installedVersion;
// App's reported installed and latest versions don't match (and it uses standard version detection)
// If they share a standard format, make sure the App's reported installed version uses that format
var correctedInstalledVersion =
reconcileVersionDifferences(app.installedVersion!, app.latestVersion);
if (correctedInstalledVersion?.key == true) {
app.installedVersion = correctedInstalledVersion!.value;
modded = true;
}
}
// FOURTH, DISABLE VERSION DETECTION IF ENABLED AND THE REPORTED/REAL INSTALLED VERSIONS ARE NOT STANDARDIZED
if (installedInfo != null &&
app.additionalSettings['versionDetection'] ==
'standardVersionDetection' &&
!isVersionDetectionPossible(AppInMemory(app, null, installedInfo))) {
app.additionalSettings['versionDetection'] = 'noVersionDetection';
logs.add('Could not reconcile version formats for: ${app.id}');
modded = true;
}
// if (app.installedVersion != null &&
// app.additionalSettings['versionDetection'] ==
// 'standardVersionDetection') {
// var correctedInstalledVersion =
// reconcileVersionDifferences(app.installedVersion!, app.latestVersion);
// if (correctedInstalledVersion == null) {
// app.additionalSettings['versionDetection'] = 'noVersionDetection';
// logs.add('Could not reconcile version formats for: ${app.id}');
// modded = true;
// }
// }
return modded ? app : null;
}
String? reconcileRealAndInternalVersions(
String realVersion, String internalVersion,
{bool matchMode = false}) {
// 1. If one or both of these can't be converted to a "standard" format, return null (leave as is)
// 2. If both have a "standard" format under which they are equal, return null (leave as is)
// 3. If both have a "standard" format in common but are unequal, return realVersion (this means it was changed externally)
// If in matchMode, the outcomes of rules 2 and 3 are reversed, and the "real" version is not matched strictly
// Matchmode to be used when comparing internal install version and internal latest version
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;
}
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);
MapEntry<bool, String>? reconcileVersionDifferences(
String templateVersion, String comparisonVersion) {
// Returns null if the versions don't share a common standard format
// Returns <true, comparisonVersion> if they share a common format and are equal
// Returns <false, templateVersion> if they share a common format but are not equal
// templateVersion must fully match a standard format, while comparisonVersion can have a substring match
var templateVersionFormats =
findStandardFormatsForVersion(templateVersion, true);
var comparisonVersionFormats =
findStandardFormatsForVersion(comparisonVersion, false);
var commonStandardFormats =
realStandardVersionFormats.intersection(internalStandardVersionFormats);
templateVersionFormats.intersection(comparisonVersionFormats);
if (commonStandardFormats.isEmpty) {
return null; // Incompatible; no "enhanced detection"
return null;
}
for (String pattern in commonStandardFormats) {
if (doStringsMatchUnderRegEx(pattern, internalVersion, realVersion)) {
return matchMode
? internalVersion
: null; // Enhanced detection says no change
if (doStringsMatchUnderRegEx(
pattern, comparisonVersion, templateVersion)) {
return MapEntry(true, comparisonVersion);
}
}
return matchMode
? null
: realVersion; // Enhanced detection says something changed
return MapEntry(false, templateVersion);
}
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 {
@ -559,7 +606,21 @@ class AppsProvider with ChangeNotifier {
List<App> newApps = (await getAppsDir())
.listSync()
.where((item) => item.path.toLowerCase().endsWith('.json'))
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
.map((e) {
try {
return App.fromJson(jsonDecode(File(e.path).readAsStringSync()));
} catch (err) {
if (err is FormatException) {
logs.add('Corrupt JSON when loading App (will be ignored): $e');
e.renameSync('${e.path}.corrupt');
return App(
'', '', '', '', '', '', [], 0, {}, DateTime.now(), false);
} else {
rethrow;
}
}
})
.where((element) => element.id.isNotEmpty)
.toList();
var idsToDelete = apps.values
.map((e) => e.app.id)
@ -602,7 +663,8 @@ class AppsProvider with ChangeNotifier {
}
Future<void> saveApps(List<App> apps,
{bool attemptToCorrectInstallStatus = true}) async {
{bool attemptToCorrectInstallStatus = true,
bool onlyIfExists = true}) async {
attemptToCorrectInstallStatus =
attemptToCorrectInstallStatus && (await doesInstalledAppsPluginWork());
for (var app in apps) {
@ -613,9 +675,15 @@ class AppsProvider with ChangeNotifier {
}
File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson()));
this.apps.update(
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
ifAbsent: () => AppInMemory(app, null, info));
try {
this.apps.update(
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();
}
@ -636,8 +704,11 @@ class AppsProvider with ChangeNotifier {
}
Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async {
var showUninstallOption =
apps.where((a) => a.installedVersion != null).isNotEmpty;
var showUninstallOption = apps
.where((a) =>
a.installedVersion != null &&
a.additionalSettings['trackOnly'] != true)
.isNotEmpty;
var values = await showDialog(
context: context,
builder: (BuildContext ctx) {
@ -686,6 +757,18 @@ class AppsProvider with ChangeNotifier {
await intent.launch();
}
addMissingCategories(SettingsProvider settingsProvider) {
var cats = settingsProvider.categories;
apps.forEach((key, value) {
for (var c in value.app.categories) {
if (!cats.containsKey(c)) {
cats[c] = generateRandomLightColor().value;
}
}
});
settingsProvider.setCategories(cats, appsProvider: this);
}
Future<App?> checkUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
SourceProvider sourceProvider = SourceProvider();
@ -798,7 +881,7 @@ class AppsProvider with ChangeNotifier {
a.installedVersion = apps[a.id]?.app.installedVersion;
}
}
await saveApps(importedApps);
await saveApps(importedApps, onlyIfExists: false);
notifyListeners();
return importedApps.length;
}
@ -818,7 +901,7 @@ class AppsProvider with ChangeNotifier {
if (apps.containsKey(app.id)) {
errorsMap.addAll({app.id: tr('appAlreadyAdded')});
} else {
await saveApps([app]);
await saveApps([app], onlyIfExists: false);
}
}
List<List<String>> errors =

View File

@ -7,6 +7,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/main.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -139,6 +141,15 @@ class SettingsProvider with ChangeNotifier {
notifyListeners();
}
bool get groupByCategory {
return prefs?.getBool('groupByCategory') ?? false;
}
set groupByCategory(bool show) {
prefs?.setBool('groupByCategory', show);
notifyListeners();
}
String? getSettingString(String settingId) {
return prefs?.getString(settingId);
}
@ -151,7 +162,22 @@ class SettingsProvider with ChangeNotifier {
Map<String, int> get categories =>
Map<String, int>.from(jsonDecode(prefs?.getString('categories') ?? '{}'));
set categories(Map<String, int> cats) {
void setCategories(Map<String, int> cats, {AppsProvider? appsProvider}) {
if (appsProvider != null) {
List<App> changedApps = appsProvider.apps.values
.map((a) {
var n1 = a.app.categories.length;
a.app.categories.removeWhere((c) => !cats.keys.contains(c));
return n1 > a.app.categories.length ? a.app : null;
})
.where((element) => element != null)
.map((e) => e as App)
.toList();
if (changedApps.isNotEmpty) {
appsProvider.saveApps(changedApps,
attemptToCorrectInstallStatus: false);
}
}
prefs?.setString('categories', jsonEncode(cats));
notifyListeners();
}

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

View File

@ -181,10 +181,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: d090ae03df98b0247b82e5928f44d1b959867049d18d73635e2e0bc3f49542b9
sha256: d8e9ca7e5d1983365c277f12c21b4362df6cf659c99af146ad4d04eb33033013
url: "https://pub.dev"
source: hosted
version: "5.2.5"
version: "5.2.6"
flutter:
dependency: "direct main"
description: flutter
@ -235,6 +235,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "7b25c10de1fea883f3c4f9b8389506b54053cd00807beab69fd65c8653a2711f"
url: "https://pub.dev"
source: hosted
version: "0.6.14"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -325,6 +333,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
markdown:
dependency: transitive
description:
name: markdown
sha256: b3c60dee8c2af50ad0e6e90cceba98e47718a6ee0a7a6772c77846a0cc21f78b
url: "https://pub.dev"
source: hosted
version: "7.0.1"
matcher:
dependency: transitive
description:
@ -393,34 +409,34 @@ packages:
dependency: "direct main"
description:
name: path_provider
sha256: "04890b994ee89bfa80bf3080bfec40d5a92c5c7a785ebb02c13084a099d2b6f9"
sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4
url: "https://pub.dev"
source: hosted
version: "2.0.13"
version: "2.0.14"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "7623b7d4be0f0f7d9a8b5ee6879fc13e4522d4c875ab86801dee4af32b54b83e"
sha256: "019f18c9c10ae370b08dce1f3e3b73bc9f58e7f087bb5e921f06529438ac0ae7"
url: "https://pub.dev"
source: hosted
version: "2.0.23"
version: "2.0.24"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: eec003594f19fe2456ea965ae36b3fc967bc5005f508890aafe31fa75e41d972
sha256: "818b2dc38b0f178e0ea3f7cf3b28146faab11375985d815942a68eee11c2d0f7"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.2.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "525ad5e07622d19447ad740b1ed5070031f7a5437f44355ae915ff56e986429a"
sha256: "2ae08f2216225427e64ad224a24354221c2c7907e448e6e0e8b57b1eb9f10ad1"
url: "https://pub.dev"
source: hosted
version: "2.1.9"
version: "2.1.10"
path_provider_platform_interface:
dependency: transitive
description:
@ -433,10 +449,10 @@ packages:
dependency: transitive
description:
name: path_provider_windows
sha256: "642ddf65fde5404f83267e8459ddb4556316d3ee6d511ed193357e25caa3632d"
sha256: f53720498d5a543f9607db4b0e997c4b5438884de25b0f73098cc2671a51b130
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
permission_handler:
dependency: "direct main"
description:
@ -457,10 +473,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163"
sha256: ee96ac32f5a8e6f80756e25b25b9f8e535816c8e6665a96b6d70681f8c4f7e85
url: "https://pub.dev"
source: hosted
version: "9.0.7"
version: "9.0.8"
permission_handler_platform_interface:
dependency: transitive
description:
@ -537,58 +553,58 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: ee6257848f822b8481691f20c3e6d2bfee2e9eccb2a3d249907fcfb198c55b41
sha256: "78528fd87d0d08ffd3e69551173c026e8eacc7b7079c82eb6a77413957b7e394"
url: "https://pub.dev"
source: hosted
version: "2.0.18"
version: "2.0.20"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: a51a4f9375097f94df1c6e0a49c0374440d31ab026b59d58a7e7660675879db4
sha256: ad423a80fe7b4e48b50d6111b3ea1027af0e959e49d485712e134863d9c1c521
url: "https://pub.dev"
source: hosted
version: "2.0.16"
version: "2.0.17"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6b84fdf06b32bb336f972d373cd38b63734f3461ba56ac2ba01b56d052796259"
sha256: "1e755f8583229f185cfca61b1d80fb2344c9d660e1c69ede5450d8f478fa5310"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: d7fb71e6e20cd3dfffcc823a28da3539b392e53ed5fc5c2b90b55fdaa8a7e8fa
sha256: "3a59ed10890a8409ad0faad7bb2957dab4b92b8fbe553257b05d30ed8af2c707"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "824bfd02713e37603b2bdade0842e47d56e7db32b1dcdd1cae533fb88e2913fc"
sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.2.0"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: "6737b757e49ba93de2a233df229d0b6a87728cea1684da828cbc718b65dcf9d7"
sha256: "0dc2633f215a3d4aa3184c9b2c5766f4711e4e5a6b256e62aafee41f89f1bfb8"
url: "https://pub.dev"
source: hosted
version: "2.0.5"
version: "2.0.6"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: bd014168e8484837c39ef21065b78f305810ceabc1d4f90be6e3b392ce81b46d
sha256: "71bcd669bb9cdb6b39f22c4a7728b6d49e934f6cba73157ffa5a54f1eed67436"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
sky_engine:
dependency: transitive
description: flutter
@ -606,18 +622,18 @@ packages:
dependency: "direct main"
description:
name: sqflite
sha256: "851d5040552cf911f4cabda08d003eca76b27da3ed0002978272e27c8fbf8ecc"
sha256: "500d6fec583d2c021f2d25a056d96654f910662c64f836cd2063167b8f1fa758"
url: "https://pub.dev"
source: hosted
version: "2.2.5"
version: "2.2.6"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: bfd6973aaeeb93475bc0d875ac9aefddf7965ef22ce09790eb963992ffc5183f
sha256: "963dad8c4aa2f814ce7d2d5b1da2f36f31bd1a439d8f27e3dc189bb9d26bc684"
url: "https://pub.dev"
source: hosted
version: "2.4.2+2"
version: "2.4.3"
stack_trace:
dependency: transitive
description:
@ -694,34 +710,34 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "1f4d9ebe86f333c15d318f81dcdc08b01d45da44af74552608455ebdc08d9732"
sha256: dd729390aa936bf1bdf5cd1bc7468ff340263f80a2c4f569416507667de8e3c8
url: "https://pub.dev"
source: hosted
version: "6.0.24"
version: "6.0.26"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: c9cd648d2f7ab56968e049d4e9116f96a85517f1dd806b96a86ea1018a3a82e5
sha256: "3dedc66ca3c0bef9e6a93c0999aee102556a450afcc1b7bcfeace7a424927d92"
url: "https://pub.dev"
source: hosted
version: "6.1.1"
version: "6.1.3"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: e29039160ab3730e42f3d811dc2a6d5f2864b90a70fb765ea60144b03307f682
sha256: "206fb8334a700ef7754d6a9ed119e7349bc830448098f21a69bf1b4ed038cabc"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.4"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "2dddb3291a57b074dade66b5e07e64401dd2487caefd4e9e2f467138d8c7eb06"
sha256: "0ef2b4f97942a16523e51256b799e9aa1843da6c60c55eefbfa9dbc2dcb8331a"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.4"
url_launcher_platform_interface:
dependency: transitive
description:
@ -734,18 +750,18 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "574cfbe2390666003c3a1d129bdc4574aaa6728f0c00a4829a81c316de69dd9b"
sha256: "81fe91b6c4f84f222d186a9d23c73157dc4c8e1c71489c4d08be1ad3b228f1aa"
url: "https://pub.dev"
source: hosted
version: "2.0.15"
version: "2.0.16"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "97c9067950a0d09cbd93e2e3f0383d1403989362b97102fbf446473a48079a4b"
sha256: a83ba3607a507758669cfafb03f9de09bf6e6280c14d9b9cb18f013e406dcacd
url: "https://pub.dev"
source: hosted
version: "3.0.4"
version: "3.0.5"
uuid:
dependency: transitive
description:
@ -766,34 +782,34 @@ packages:
dependency: "direct main"
description:
name: webview_flutter
sha256: b6cd42db3ced5411f3d01599906156885b18e4188f7065a8a351eb84bee347e0
sha256: "47663d51a9061451aa3880a214ee9a65dcbb933b77bc44388e194279ab3ccaf6"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
version: "4.0.7"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "5dd3f32b5c2d8f4bf9d05a349e4a65fa718eb137f396f336c3893d558a58fe84"
sha256: "34f83c2f0f64c75ad75c77a2ccfc8d2e531afbe8ad41af1fd787d6d33336aa90"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
version: "3.4.3"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: df6472164b3f4eaf3280422227f361dc8424b106726b7f21d79a8656ba53f71f
sha256: "1939c39e2150fb4d30fd3cc59a891a49fed9935db53007df633ed83581b6117b"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.1.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "87b6353b40e04f04d5f895a484ad6d92d682d9cce4d2d5b32d2d8aca2448d46e"
sha256: d601aba11ad8d4481e17a34a76fa1d30dee92dcbbe2c58b0df3120e9453099c7
url: "https://pub.dev"
source: hosted
version: "3.2.0"
version: "3.2.3"
win32:
dependency: transitive
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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.11.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:
sdk: '>=2.18.2 <3.0.0'
@ -59,6 +59,7 @@ dependencies:
sqflite: ^2.2.0+3
easy_localization: ^3.0.1
android_intent_plus: ^3.1.5
flutter_markdown: ^0.6.14
dev_dependencies:
@ -91,6 +92,7 @@ flutter:
assets:
- assets/translations/
- assets/graphics/
- assets/ca/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware