mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 13:26:43 +02:00
Compare commits
29 Commits
v0.6.0-bet
...
v0.6.11-be
Author | SHA1 | Date | |
---|---|---|---|
e158c23cca | |||
208f125e12 | |||
b7ccf3fa49 | |||
c746e89052 | |||
ee758e8470 | |||
68d903e092 | |||
c47b752344 | |||
62a05996cf | |||
1cda941fbe | |||
49cb908d04 | |||
139f44d31d | |||
ed955ac6a2 | |||
f3ead6caf1 | |||
97ab723d04 | |||
ed4a26d348 | |||
bd5f21984e | |||
5037d77b14 | |||
c9711c7734 | |||
76e98feeb7 | |||
03da23f77a | |||
9b99e2b302 | |||
e746ca890a | |||
9c00a7da14 | |||
4df0dd64ad | |||
7cf7ffe0de | |||
b1953435af | |||
fc7d7d11d6 | |||
9ef26b3a4a | |||
27ee6b9e88 |
@ -30,7 +30,25 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<service
|
||||||
|
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
|
||||||
|
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||||
|
android:exported="false"/>
|
||||||
|
<receiver
|
||||||
|
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
|
||||||
|
android:exported="false"/>
|
||||||
|
<receiver
|
||||||
|
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
</manifest>
|
</manifest>
|
@ -1,112 +0,0 @@
|
|||||||
import 'package:html/parser.dart';
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
|
||||||
|
|
||||||
class APKMirror implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'apkmirror.com';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw notValidURL(runtimeType.toString());
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
|
||||||
'$standardUrl#whatsnew';
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
|
||||||
var originalUri = Uri.parse(apkUrl);
|
|
||||||
var res = await get(originalUri);
|
|
||||||
if (res.statusCode != 200) {
|
|
||||||
throw false;
|
|
||||||
}
|
|
||||||
var href =
|
|
||||||
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
|
|
||||||
if (href == null) {
|
|
||||||
throw false;
|
|
||||||
}
|
|
||||||
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
|
|
||||||
'User-Agent':
|
|
||||||
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
|
||||||
});
|
|
||||||
if (res2.statusCode != 200) {
|
|
||||||
throw false;
|
|
||||||
}
|
|
||||||
var links = parse(res2.body)
|
|
||||||
.querySelectorAll('a')
|
|
||||||
.where((element) => element.innerHtml == 'here')
|
|
||||||
.map((e) => e.attributes['href'])
|
|
||||||
.where((element) => element != null)
|
|
||||||
.toList();
|
|
||||||
if (links.isEmpty) {
|
|
||||||
throw false;
|
|
||||||
}
|
|
||||||
return '${originalUri.origin}${links[0]}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(
|
|
||||||
String standardUrl, List<String> additionalData) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/feed'));
|
|
||||||
if (res.statusCode != 200) {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
var nextUrl = parse(res.body)
|
|
||||||
.querySelector('item')
|
|
||||||
?.querySelector('link')
|
|
||||||
?.nextElementSibling
|
|
||||||
?.innerHtml;
|
|
||||||
if (nextUrl == null) {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
Response res2 = await get(Uri.parse(nextUrl), headers: {
|
|
||||||
'User-Agent':
|
|
||||||
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
|
||||||
});
|
|
||||||
if (res2.statusCode != 200) {
|
|
||||||
throw couldNotFindReleases;
|
|
||||||
}
|
|
||||||
var html2 = parse(res2.body);
|
|
||||||
var origin = Uri.parse(standardUrl).origin;
|
|
||||||
List<String> apkUrls = html2
|
|
||||||
.querySelectorAll('.apkm-badge')
|
|
||||||
.map((e) => e.innerHtml != 'APK'
|
|
||||||
? ''
|
|
||||||
: e.previousElementSibling?.attributes['href'] ?? '')
|
|
||||||
.where((element) => element.isNotEmpty)
|
|
||||||
.map((e) => '$origin$e')
|
|
||||||
.toList();
|
|
||||||
if (apkUrls.isEmpty) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
|
||||||
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
|
|
||||||
if (version == null) {
|
|
||||||
throw couldNotFindLatestVersion;
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
|
||||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
|
||||||
return AppNames(names[1], names[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<String> additionalDataDefaults = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
|
||||||
}
|
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:html/parser.dart';
|
import 'package:html/parser.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class FDroid implements AppSource {
|
class FDroid implements AppSource {
|
||||||
@ -18,7 +19,7 @@ class FDroid implements AppSource {
|
|||||||
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
||||||
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw notValidURL(runtimeType.toString());
|
throw InvalidURLError(runtimeType.toString());
|
||||||
}
|
}
|
||||||
return url.substring(0, match.end);
|
return url.substring(0, match.end);
|
||||||
}
|
}
|
||||||
@ -34,25 +35,41 @@ class FDroid implements AppSource {
|
|||||||
String standardUrl, List<String> additionalData) async {
|
String standardUrl, List<String> additionalData) async {
|
||||||
Response res = await get(Uri.parse(standardUrl));
|
Response res = await get(Uri.parse(standardUrl));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var latestReleaseDiv =
|
var releases = parse(res.body).querySelectorAll('.package-version');
|
||||||
parse(res.body).querySelector('#latest.package-version');
|
if (releases.isEmpty) {
|
||||||
var apkUrl = latestReleaseDiv
|
throw NoReleasesError();
|
||||||
?.querySelector('.package-version-download a')
|
|
||||||
?.attributes['href'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw noAPKFound;
|
|
||||||
}
|
}
|
||||||
var version = latestReleaseDiv
|
String? latestVersion = releases[0]
|
||||||
?.querySelector('.package-version-header b')
|
.querySelector('.package-version-header b')
|
||||||
?.innerHtml
|
?.innerHtml
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.last;
|
.sublist(1)
|
||||||
if (version == null) {
|
.join(' ');
|
||||||
throw couldNotFindLatestVersion;
|
if (latestVersion == null) {
|
||||||
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, [apkUrl]);
|
List<String> apkUrls = releases
|
||||||
|
.where((element) =>
|
||||||
|
element
|
||||||
|
.querySelector('.package-version-header b')
|
||||||
|
?.innerHtml
|
||||||
|
.split(' ')
|
||||||
|
.sublist(1)
|
||||||
|
.join(' ') ==
|
||||||
|
latestVersion)
|
||||||
|
.map((e) =>
|
||||||
|
e
|
||||||
|
.querySelector('.package-version-download a')
|
||||||
|
?.attributes['href'] ??
|
||||||
|
'')
|
||||||
|
.where((element) => element.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (apkUrls.isEmpty) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
return APKDetails(latestVersion, apkUrls);
|
||||||
} else {
|
} else {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ class GitHub implements AppSource {
|
|||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw notValidURL(runtimeType.toString());
|
throw InvalidURLError(runtimeType.toString());
|
||||||
}
|
}
|
||||||
return url.substring(0, match.end);
|
return url.substring(0, match.end);
|
||||||
}
|
}
|
||||||
@ -69,6 +69,7 @@ class GitHub implements AppSource {
|
|||||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regexFilter != null &&
|
if (regexFilter != null &&
|
||||||
!RegExp(regexFilter)
|
!RegExp(regexFilter)
|
||||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||||
@ -83,14 +84,14 @@ class GitHub implements AppSource {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (targetRelease == null) {
|
if (targetRelease == null) {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||||
throw noAPKFound;
|
throw NoAPKError();
|
||||||
}
|
}
|
||||||
String? version = targetRelease['tag_name'];
|
String? version = targetRelease['tag_name'];
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw couldNotFindLatestVersion;
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, targetRelease['apkUrls']);
|
return APKDetails(version, targetRelease['apkUrls']);
|
||||||
} else {
|
} else {
|
||||||
@ -101,7 +102,7 @@ class GitHub implements AppSource {
|
|||||||
.round());
|
.round());
|
||||||
}
|
}
|
||||||
|
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import 'package:html/parser.dart';
|
|||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/app_sources/github.dart';
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class GitLab implements AppSource {
|
class GitLab implements AppSource {
|
||||||
@ -13,7 +14,7 @@ class GitLab implements AppSource {
|
|||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw notValidURL(runtimeType.toString());
|
throw InvalidURLError(runtimeType.toString());
|
||||||
}
|
}
|
||||||
return url.substring(0, match.end);
|
return url.substring(0, match.end);
|
||||||
}
|
}
|
||||||
@ -39,7 +40,9 @@ class GitLab implements AppSource {
|
|||||||
...getLinksFromParsedHTML(
|
...getLinksFromParsedHTML(
|
||||||
entryContent,
|
entryContent,
|
||||||
RegExp(
|
RegExp(
|
||||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||||
|
return '\\${x[0]}';
|
||||||
|
})}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||||
caseSensitive: false),
|
caseSensitive: false),
|
||||||
standardUri.origin),
|
standardUri.origin),
|
||||||
// GitLab releases may contain links to externally hosted APKs
|
// GitLab releases may contain links to externally hosted APKs
|
||||||
@ -49,18 +52,18 @@ class GitLab implements AppSource {
|
|||||||
.toList()
|
.toList()
|
||||||
];
|
];
|
||||||
if (apkUrlList.isEmpty) {
|
if (apkUrlList.isEmpty) {
|
||||||
throw noAPKFound;
|
throw NoAPKError();
|
||||||
}
|
}
|
||||||
|
|
||||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||||
var version =
|
var version =
|
||||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw couldNotFindLatestVersion;
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, apkUrlList);
|
return APKDetails(version, apkUrlList);
|
||||||
} else {
|
} else {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:html/parser.dart';
|
import 'package:html/parser.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class IzzyOnDroid implements AppSource {
|
class IzzyOnDroid implements AppSource {
|
||||||
@ -12,7 +13,7 @@ class IzzyOnDroid implements AppSource {
|
|||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw notValidURL(runtimeType.toString());
|
throw InvalidURLError(runtimeType.toString());
|
||||||
}
|
}
|
||||||
return url.substring(0, match.end);
|
return url.substring(0, match.end);
|
||||||
}
|
}
|
||||||
@ -37,7 +38,7 @@ class IzzyOnDroid implements AppSource {
|
|||||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||||
.toList();
|
.toList();
|
||||||
if (multipleVersionApkUrls.isEmpty) {
|
if (multipleVersionApkUrls.isEmpty) {
|
||||||
throw noAPKFound;
|
throw NoAPKError();
|
||||||
}
|
}
|
||||||
var version = parsedHtml
|
var version = parsedHtml
|
||||||
.querySelector('#keydata')
|
.querySelector('#keydata')
|
||||||
@ -50,11 +51,11 @@ class IzzyOnDroid implements AppSource {
|
|||||||
?.children[1]
|
?.children[1]
|
||||||
.innerHtml;
|
.innerHtml;
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw couldNotFindLatestVersion;
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||||
} else {
|
} else {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:html/parser.dart';
|
import 'package:html/parser.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class Mullvad implements AppSource {
|
class Mullvad implements AppSource {
|
||||||
@ -12,7 +13,7 @@ class Mullvad implements AppSource {
|
|||||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw notValidURL(runtimeType.toString());
|
throw InvalidURLError(runtimeType.toString());
|
||||||
}
|
}
|
||||||
return url.substring(0, match.end);
|
return url.substring(0, match.end);
|
||||||
}
|
}
|
||||||
@ -36,12 +37,12 @@ class Mullvad implements AppSource {
|
|||||||
?.split('/')
|
?.split('/')
|
||||||
.last;
|
.last;
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw couldNotFindLatestVersion;
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(
|
return APKDetails(
|
||||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||||
} else {
|
} else {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class Signal implements AppSource {
|
class Signal implements AppSource {
|
||||||
@ -27,15 +28,15 @@ class Signal implements AppSource {
|
|||||||
var json = jsonDecode(res.body);
|
var json = jsonDecode(res.body);
|
||||||
String? apkUrl = json['url'];
|
String? apkUrl = json['url'];
|
||||||
if (apkUrl == null) {
|
if (apkUrl == null) {
|
||||||
throw noAPKFound;
|
throw NoAPKError();
|
||||||
}
|
}
|
||||||
String? version = json['versionName'];
|
String? version = json['versionName'];
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw couldNotFindLatestVersion;
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, [apkUrl]);
|
return APKDetails(version, [apkUrl]);
|
||||||
} else {
|
} else {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:html/parser.dart';
|
import 'package:html/parser.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class SourceForge implements AppSource {
|
class SourceForge implements AppSource {
|
||||||
@ -12,7 +13,7 @@ class SourceForge implements AppSource {
|
|||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
|
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw notValidURL(runtimeType.toString());
|
throw InvalidURLError(runtimeType.toString());
|
||||||
}
|
}
|
||||||
return url.substring(0, match.end);
|
return url.substring(0, match.end);
|
||||||
}
|
}
|
||||||
@ -42,7 +43,7 @@ class SourceForge implements AppSource {
|
|||||||
|
|
||||||
String? version = getVersion(allDownloadLinks[0]);
|
String? version = getVersion(allDownloadLinks[0]);
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
throw couldNotFindLatestVersion;
|
throw NoVersionError();
|
||||||
}
|
}
|
||||||
var apkUrlListAllReleases = allDownloadLinks
|
var apkUrlListAllReleases = allDownloadLinks
|
||||||
.where((element) => element.toLowerCase().endsWith('.apk/download'))
|
.where((element) => element.toLowerCase().endsWith('.apk/download'))
|
||||||
@ -52,11 +53,11 @@ class SourceForge implements AppSource {
|
|||||||
.where((element) => getVersion(element) == version)
|
.where((element) => getVersion(element) == version)
|
||||||
.toList();
|
.toList();
|
||||||
if (apkUrlList.isEmpty) {
|
if (apkUrlList.isEmpty) {
|
||||||
throw noAPKFound;
|
throw NoAPKError();
|
||||||
}
|
}
|
||||||
return APKDetails(version, apkUrlList);
|
return APKDetails(version, apkUrlList);
|
||||||
} else {
|
} else {
|
||||||
throw couldNotFindReleases;
|
throw NoReleasesError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +28,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
values = widget.defaultValues;
|
||||||
valid = widget.initValid;
|
valid = widget.initValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,15 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
|
||||||
|
class ObtainiumError {
|
||||||
|
late String message;
|
||||||
|
ObtainiumError(this.message);
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class RateLimitError {
|
class RateLimitError {
|
||||||
late int remainingMinutes;
|
late int remainingMinutes;
|
||||||
RateLimitError(this.remainingMinutes);
|
RateLimitError(this.remainingMinutes);
|
||||||
@ -6,3 +18,97 @@ class RateLimitError {
|
|||||||
String toString() =>
|
String toString() =>
|
||||||
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
|
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InvalidURLError extends ObtainiumError {
|
||||||
|
InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoReleasesError extends ObtainiumError {
|
||||||
|
NoReleasesError() : super('Could not find a suitable release');
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoAPKError extends ObtainiumError {
|
||||||
|
NoAPKError() : super('Could not find a suitable release');
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoVersionError extends ObtainiumError {
|
||||||
|
NoVersionError() : super('Could not determine release version');
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnsupportedURLError extends ObtainiumError {
|
||||||
|
UnsupportedURLError() : super('URL does not match a known source');
|
||||||
|
}
|
||||||
|
|
||||||
|
class DowngradeError extends ObtainiumError {
|
||||||
|
DowngradeError() : super('Cannot install an older version of an App');
|
||||||
|
}
|
||||||
|
|
||||||
|
class IDChangedError extends ObtainiumError {
|
||||||
|
IDChangedError()
|
||||||
|
: super('Downloaded package ID does not match existing App ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiAppMultiError extends ObtainiumError {
|
||||||
|
Map<String, List<String>> content = {};
|
||||||
|
|
||||||
|
MultiAppMultiError() : super('Multiple Errors Placeholder');
|
||||||
|
|
||||||
|
add(String appId, String string) {
|
||||||
|
var tempIds = content.remove(string);
|
||||||
|
tempIds ??= [];
|
||||||
|
tempIds.add(appId);
|
||||||
|
content.putIfAbsent(string, () => tempIds!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
String finalString = '';
|
||||||
|
for (var e in content.keys) {
|
||||||
|
finalString += '$e: ${content[e].toString()}\n\n';
|
||||||
|
}
|
||||||
|
return finalString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(dynamic e, BuildContext context) {
|
||||||
|
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(e is MultiAppMultiError
|
||||||
|
? 'Some Errors Occurred'
|
||||||
|
: 'Unexpected Error'),
|
||||||
|
content: Text(e.toString()),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Ok')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String list2FriendlyString(List<String> list) {
|
||||||
|
return list.length == 2
|
||||||
|
? '${list[0]} and ${list[1]}'
|
||||||
|
: list
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) =>
|
||||||
|
e.value +
|
||||||
|
(e.key == list.length - 1
|
||||||
|
? ''
|
||||||
|
: e.key == list.length - 2
|
||||||
|
? ', and '
|
||||||
|
: ', '))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -10,52 +11,52 @@ import 'package:obtainium/providers/settings_provider.dart';
|
|||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||||
|
|
||||||
const String currentVersion = '0.6.0';
|
const String currentVersion = '0.6.11';
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
const String bgUpdateCheckTaskName = 'bg-update-check';
|
const int bgUpdateCheckAlarmId = 666;
|
||||||
|
|
||||||
bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||||
|
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await AndroidAlarmManager.initialize();
|
||||||
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||||
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||||
: null;
|
: null;
|
||||||
var notificationsProvider = NotificationsProvider();
|
var notificationsProvider = NotificationsProvider();
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
try {
|
try {
|
||||||
var appsProvider = AppsProvider();
|
var appsProvider = AppsProvider(forBGTask: true);
|
||||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
await appsProvider.loadApps(shouldCorrectInstallStatus: false);
|
await appsProvider.loadApps();
|
||||||
List<String> existingUpdateIds =
|
List<String> existingUpdateIds =
|
||||||
appsProvider.getExistingUpdates(installedOnly: true);
|
appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
DateTime nextIgnoreAfter = DateTime.now();
|
DateTime nextIgnoreAfter = DateTime.now();
|
||||||
String? err;
|
String? err;
|
||||||
try {
|
try {
|
||||||
await appsProvider.checkUpdates(
|
await appsProvider.checkUpdates(
|
||||||
ignoreAfter: ignoreAfter,
|
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||||
immediatelyThrowRateLimitError: true,
|
|
||||||
immediatelyThrowSocketError: true,
|
|
||||||
shouldCorrectInstallStatus: false);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is RateLimitError || e is SocketException) {
|
if (e is RateLimitError || e is SocketException) {
|
||||||
String nextTaskName =
|
AndroidAlarmManager.oneShot(
|
||||||
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
|
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
||||||
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
|
Random().nextInt(pow(2, 31) as int),
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
bgUpdateCheck,
|
||||||
initialDelay: Duration(
|
params: {
|
||||||
minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||||
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
|
});
|
||||||
} else {
|
} else {
|
||||||
err = e.toString();
|
err = e.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<App> newUpdates = appsProvider
|
List<App> newUpdates = appsProvider
|
||||||
.getExistingUpdates(installedOnly: true)
|
.findExistingUpdates(installedOnly: true)
|
||||||
.where((id) => !existingUpdateIds.contains(id))
|
.where((id) => !existingUpdateIds.contains(id))
|
||||||
.map((e) => appsProvider.apps[e]!.app)
|
.map((e) => appsProvider.apps[e]!.app)
|
||||||
.toList();
|
.toList();
|
||||||
@ -80,24 +81,14 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
|||||||
if (err != null) {
|
if (err != null) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return Future.value(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notificationsProvider
|
notificationsProvider
|
||||||
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||||
return Future.error(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
void bgTaskCallback() {
|
|
||||||
// Background process callback
|
|
||||||
Workmanager().executeTask((task, inputData) async {
|
|
||||||
return await bgUpdateCheck(inputData?['ignoreAfter']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||||
@ -106,16 +97,10 @@ void main() async {
|
|||||||
);
|
);
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
}
|
}
|
||||||
Workmanager().initialize(
|
await AndroidAlarmManager.initialize();
|
||||||
bgTaskCallback,
|
|
||||||
);
|
|
||||||
runApp(MultiProvider(
|
runApp(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||||
create: (context) => AppsProvider(
|
|
||||||
shouldLoadApps: true,
|
|
||||||
shouldCheckUpdatesAfterLoad: false,
|
|
||||||
shouldDeleteAPKs: true)),
|
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider())
|
||||||
],
|
],
|
||||||
@ -165,17 +150,14 @@ class _ObtainiumState extends State<Obtainium> {
|
|||||||
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||||
existingUpdateInterval = settingsProvider.updateInterval;
|
existingUpdateInterval = settingsProvider.updateInterval;
|
||||||
if (existingUpdateInterval == 0) {
|
if (existingUpdateInterval == 0) {
|
||||||
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
|
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||||
} else {
|
} else {
|
||||||
Workmanager().registerPeriodicTask(
|
AndroidAlarmManager.periodic(
|
||||||
bgUpdateCheckTaskName, bgUpdateCheckTaskName,
|
Duration(minutes: existingUpdateInterval),
|
||||||
frequency: Duration(minutes: existingUpdateInterval),
|
bgUpdateCheckAlarmId,
|
||||||
initialDelay: Duration(minutes: existingUpdateInterval),
|
bgUpdateCheck,
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
rescheduleOnReboot: true,
|
||||||
existingWorkPolicy: ExistingWorkPolicy.replace,
|
wakeup: true);
|
||||||
backoffPolicy: BackoffPolicy.linear,
|
|
||||||
backoffPolicyDelay:
|
|
||||||
const Duration(minutes: minUpdateIntervalMinutes));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import 'package:obtainium/app_sources/github.dart';
|
|||||||
import 'package:obtainium/custom_errors.dart';
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
class GitHubStars implements MassAppSource {
|
class GitHubStars implements MassAppUrlSource {
|
||||||
@override
|
@override
|
||||||
late String name = 'GitHub Starred Repos';
|
late String name = 'GitHub Starred Repos';
|
||||||
|
|
||||||
@ -28,14 +28,14 @@ class GitHubStars implements MassAppSource {
|
|||||||
.round());
|
.round());
|
||||||
}
|
}
|
||||||
|
|
||||||
throw 'Unable to find user\'s starred repos';
|
throw ObtainiumError('Unable to find user\'s starred repos');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<String>> getUrls(List<String> args) async {
|
Future<List<String>> getUrls(List<String> args) async {
|
||||||
if (args.length != requiredArgs.length) {
|
if (args.length != requiredArgs.length) {
|
||||||
throw 'Wrong number of arguments provided';
|
throw ObtainiumError('Wrong number of arguments provided');
|
||||||
}
|
}
|
||||||
List<String> urls = [];
|
List<String> urls = [];
|
||||||
var page = 1;
|
var page = 1;
|
||||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -56,7 +57,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return e is String
|
return e is String
|
||||||
? e
|
? e
|
||||||
: 'Error';
|
: e is ObtainiumError
|
||||||
|
? e.toString()
|
||||||
|
: 'Error';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -76,7 +79,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
: [];
|
: [];
|
||||||
validAdditionalData = source != null
|
validAdditionalData = source != null
|
||||||
? sourceProvider
|
? sourceProvider
|
||||||
.doesSourceHaveRequiredAdditionalData(
|
.ifSourceAppsRequireAdditionalData(
|
||||||
source)
|
source)
|
||||||
: true;
|
: true;
|
||||||
}
|
}
|
||||||
@ -114,9 +117,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
.getInstallPermission();
|
.getInstallPermission();
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
var apkUrl = await appsProvider
|
var apkUrl = await appsProvider
|
||||||
.selectApkUrl(app, context);
|
.confirmApkUrl(app, context);
|
||||||
if (apkUrl == null) {
|
if (apkUrl == null) {
|
||||||
throw 'Cancelled';
|
throw ObtainiumError('Cancelled');
|
||||||
}
|
}
|
||||||
app.preferredApkIndex =
|
app.preferredApkIndex =
|
||||||
app.apkUrls.indexOf(apkUrl);
|
app.apkUrls.indexOf(apkUrl);
|
||||||
@ -126,7 +129,8 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
app.id = downloadedApk.appId;
|
app.id = downloadedApk.appId;
|
||||||
if (appsProvider.apps
|
if (appsProvider.apps
|
||||||
.containsKey(app.id)) {
|
.containsKey(app.id)) {
|
||||||
throw 'App already added';
|
throw ObtainiumError(
|
||||||
|
'App already added');
|
||||||
}
|
}
|
||||||
await appsProvider.saveApps([app]);
|
await appsProvider.saveApps([app]);
|
||||||
|
|
||||||
@ -140,11 +144,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
AppPage(
|
AppPage(
|
||||||
appId: app.id)));
|
appId: app.id)));
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(e, context);
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
}).whenComplete(() {
|
||||||
setState(() {
|
setState(() {
|
||||||
gettingAppInfo = false;
|
gettingAppInfo = false;
|
||||||
@ -154,7 +154,8 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
child: const Text('Add'))
|
child: const Text('Add'))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (pickedSource != null)
|
if (pickedSource != null &&
|
||||||
|
pickedSource!.additionalDataDefaults.isNotEmpty)
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@ -194,9 +195,6 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// const SizedBox(
|
|
||||||
// height: 48,
|
|
||||||
// ),
|
|
||||||
const Text(
|
const Text(
|
||||||
'Supported Sources:',
|
'Supported Sources:',
|
||||||
),
|
),
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
@ -25,10 +26,8 @@ class _AppPageState extends State<AppPage> {
|
|||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var settingsProvider = context.watch<SettingsProvider>();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
getUpdate(String id) {
|
getUpdate(String id) {
|
||||||
appsProvider.getUpdate(id).catchError((e) {
|
appsProvider.checkUpdate(id).catchError((e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showError(e, context);
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,10 +61,14 @@ class _AppPageState extends State<AppPage> {
|
|||||||
children: [
|
children: [
|
||||||
Image.memory(
|
Image.memory(
|
||||||
app!.installedInfo!.icon!,
|
app!.installedInfo!.icon!,
|
||||||
scale: 1.5,
|
height: 150,
|
||||||
|
gaplessPlayback: true,
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
: Container(),
|
: Container(),
|
||||||
|
const SizedBox(
|
||||||
|
height: 25,
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
app?.installedInfo?.name ?? app?.app.name ?? 'App',
|
app?.installedInfo?.name ?? app?.app.name ?? 'App',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@ -213,9 +216,8 @@ class _AppPageState extends State<AppPage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (app?.app.installedVersion == null ||
|
onPressed: (app?.app.installedVersion == null ||
|
||||||
appsProvider
|
app?.app.installedVersion !=
|
||||||
.checkAppObjectForUpdate(
|
app?.app.latestVersion) &&
|
||||||
app!.app)) &&
|
|
||||||
!appsProvider.areDownloadsRunning()
|
!appsProvider.areDownloadsRunning()
|
||||||
? () {
|
? () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
@ -227,11 +229,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(e, context);
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -23,6 +24,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
var updatesOnlyFilter =
|
var updatesOnlyFilter =
|
||||||
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||||
Set<String> selectedIds = {};
|
Set<String> selectedIds = {};
|
||||||
|
DateTime? refreshingSince;
|
||||||
|
|
||||||
clearSelected() {
|
clearSelected() {
|
||||||
if (selectedIds.isNotEmpty) {
|
if (selectedIds.isNotEmpty) {
|
||||||
@ -119,28 +121,46 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
sortedApps = sortedApps.reversed.toList();
|
sortedApps = sortedApps.reversed.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingUpdateIdsAllOrSelected = appsProvider
|
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
.getExistingUpdates(installedOnly: true)
|
|
||||||
|
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||||
.where((element) => selectedIds.isEmpty
|
.where((element) => selectedIds.isEmpty
|
||||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
: selectedIds.contains(element))
|
: selectedIds.contains(element))
|
||||||
.toList();
|
.toList();
|
||||||
var newInstallIdsAllOrSelected = appsProvider
|
var newInstallIdsAllOrSelected = appsProvider
|
||||||
.getExistingUpdates(nonInstalledOnly: true)
|
.findExistingUpdates(nonInstalledOnly: true)
|
||||||
.where((element) => selectedIds.isEmpty
|
.where((element) => selectedIds.isEmpty
|
||||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
: selectedIds.contains(element))
|
: selectedIds.contains(element))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (settingsProvider.pinUpdates) {
|
||||||
|
var temp = [];
|
||||||
|
sortedApps = sortedApps.where((sa) {
|
||||||
|
if (existingUpdates.contains(sa.app.id)) {
|
||||||
|
temp.add(sa);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
sortedApps = [...temp, ...sortedApps];
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () {
|
onRefresh: () {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
setState(() {
|
||||||
|
refreshingSince = DateTime.now();
|
||||||
|
});
|
||||||
return appsProvider.checkUpdates().catchError((e) {
|
return appsProvider.checkUpdates().catchError((e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showError(e, context);
|
||||||
SnackBar(content: Text(e.toString())),
|
}).whenComplete(() {
|
||||||
);
|
setState(() {
|
||||||
|
refreshingSince = null;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: CustomScrollView(slivers: <Widget>[
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
@ -157,6 +177,17 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
))),
|
))),
|
||||||
|
if (refreshingSince != null)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: appsProvider.apps.values
|
||||||
|
.where((element) => !(element.app.lastUpdateCheck
|
||||||
|
?.isBefore(refreshingSince!) ??
|
||||||
|
true))
|
||||||
|
.length /
|
||||||
|
appsProvider.apps.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
@ -168,7 +199,10 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
toggleAppSelected(sortedApps[index].app.id);
|
toggleAppSelected(sortedApps[index].app.id);
|
||||||
},
|
},
|
||||||
leading: sortedApps[index].installedInfo != null
|
leading: sortedApps[index].installedInfo != null
|
||||||
? Image.memory(sortedApps[index].installedInfo!.icon!)
|
? Image.memory(
|
||||||
|
sortedApps[index].installedInfo!.icon!,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
title: Text(sortedApps[index].installedInfo?.name ??
|
title: Text(sortedApps[index].installedInfo?.name ??
|
||||||
sortedApps[index].app.name),
|
sortedApps[index].app.name),
|
||||||
@ -212,8 +246,15 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
)),
|
)),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: Text(sortedApps[index].app.installedVersion ??
|
: SingleChildScrollView(
|
||||||
'Not Installed')),
|
child: SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
sortedApps[index].app.installedVersion ??
|
||||||
|
'Not Installed',
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
)))),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (selectedIds.isNotEmpty) {
|
if (selectedIds.isNotEmpty) {
|
||||||
toggleAppSelected(sortedApps[index].app.id);
|
toggleAppSelected(sortedApps[index].app.id);
|
||||||
@ -310,15 +351,18 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
message:
|
message:
|
||||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||||
items: formInputs,
|
items: formInputs,
|
||||||
defaultValues: const ['true'],
|
defaultValues: [
|
||||||
|
'true',
|
||||||
|
existingUpdateIdsAllOrSelected.isEmpty
|
||||||
|
? 'true'
|
||||||
|
: ''
|
||||||
|
],
|
||||||
initValid: true,
|
initValid: true,
|
||||||
);
|
);
|
||||||
}).then((values) {
|
}).then((values) {
|
||||||
if (values != null) {
|
if (values != null) {
|
||||||
bool shouldInstallUpdates =
|
bool shouldInstallUpdates = values[0] == 'true';
|
||||||
values.length < 2 || values[0] == 'true';
|
bool shouldInstallNew = values[1] == 'true';
|
||||||
bool shouldInstallNew =
|
|
||||||
values.length >= 2 && values[1] == 'true';
|
|
||||||
settingsProvider
|
settingsProvider
|
||||||
.getInstallPermission()
|
.getInstallPermission()
|
||||||
.then((_) {
|
.then((_) {
|
||||||
@ -335,9 +379,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
.downloadAndInstallLatestApps(
|
.downloadAndInstallLatestApps(
|
||||||
toInstall, context)
|
toInstall, context)
|
||||||
.catchError((e) {
|
.catchError((e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showError(e, context);
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -92,7 +92,6 @@ class _HomePageState extends State<HomePage> {
|
|||||||
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
|
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
|
||||||
.currentState
|
.currentState
|
||||||
?.clearSelected();
|
?.clearSelected();
|
||||||
// return !appsPageKey.currentState?.clearSelected();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:obtainium/components/custom_app_bar.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
import 'package:obtainium/components/generated_form_modal.dart';
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
@ -61,7 +62,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
body: CustomScrollView(slivers: <Widget>[
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
const CustomAppBar(title: 'Import/Export'),
|
const CustomAppBar(title: 'Import/Export'),
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
@ -81,12 +81,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
appsProvider
|
appsProvider
|
||||||
.exportApps()
|
.exportApps()
|
||||||
.then((String path) {
|
.then((String path) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(
|
||||||
.showSnackBar(
|
'Exported to $path', context);
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Exported to $path')),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text('Obtainium Export'))),
|
child: const Text('Obtainium Export'))),
|
||||||
@ -113,27 +109,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
try {
|
try {
|
||||||
jsonDecode(data);
|
jsonDecode(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw 'Invalid input';
|
throw ObtainiumError(
|
||||||
|
'Invalid input');
|
||||||
}
|
}
|
||||||
appsProvider
|
appsProvider
|
||||||
.importApps(data)
|
.importApps(data)
|
||||||
.then((value) {
|
.then((value) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(
|
||||||
.showSnackBar(
|
'$value App${value == 1 ? '' : 's'} Imported',
|
||||||
SnackBar(
|
context);
|
||||||
content: Text(
|
|
||||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// User canceled the picker
|
// User canceled the picker
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(e, context);
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
}).whenComplete(() {
|
||||||
setState(() {
|
setState(() {
|
||||||
importInProgress = false;
|
importInProgress = false;
|
||||||
@ -208,12 +198,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
addApps(urls).then((errors) {
|
addApps(urls).then((errors) {
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(
|
||||||
.showSnackBar(
|
'Imported ${urls.length} Apps',
|
||||||
SnackBar(
|
context);
|
||||||
content: Text(
|
|
||||||
'Imported ${urls.length} Apps')),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -224,10 +211,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
showError(e, context);
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
}).whenComplete(() {
|
||||||
setState(() {
|
setState(() {
|
||||||
importInProgress = false;
|
importInProgress = false;
|
||||||
@ -239,7 +223,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
child: const Text(
|
child: const Text(
|
||||||
'Import from URL List',
|
'Import from URL List',
|
||||||
)),
|
)),
|
||||||
...sourceProvider.massSources
|
...sourceProvider.massUrlSources
|
||||||
.map((source) => Column(
|
.map((source) => Column(
|
||||||
crossAxisAlignment:
|
crossAxisAlignment:
|
||||||
CrossAxisAlignment.stretch,
|
CrossAxisAlignment.stretch,
|
||||||
@ -288,13 +272,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
.then((errors) {
|
.then((errors) {
|
||||||
if (errors
|
if (errors
|
||||||
.isEmpty) {
|
.isEmpty) {
|
||||||
ScaffoldMessenger
|
showError(
|
||||||
.of(context)
|
'Imported ${selectedUrls.length} Apps',
|
||||||
.showSnackBar(
|
context);
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Imported ${selectedUrls.length} Apps')),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
showDialog(
|
||||||
context:
|
context:
|
||||||
@ -328,20 +308,26 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
importInProgress =
|
importInProgress =
|
||||||
false;
|
false;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(
|
showError(e, context);
|
||||||
context)
|
|
||||||
.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
e.toString())),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text('Import ${source.name}'))
|
child: Text('Import ${source.name}'))
|
||||||
]))
|
]))
|
||||||
.toList()
|
.toList(),
|
||||||
|
const Spacer(),
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Imported Apps may incorrectly show as "Not Installed".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
)))
|
)))
|
||||||
]));
|
]));
|
||||||
|
@ -21,6 +21,143 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var themeDropdown = DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Theme'),
|
||||||
|
value: settingsProvider.theme,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ThemeSettings.dark,
|
||||||
|
child: Text('Dark'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ThemeSettings.light,
|
||||||
|
child: Text('Light'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ThemeSettings.system,
|
||||||
|
child: Text('Follow System'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.theme = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var colourDropdown = DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Colour'),
|
||||||
|
value: settingsProvider.colour,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ColourSettings.basic,
|
||||||
|
child: Text('Obtainium'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ColourSettings.materialYou,
|
||||||
|
child: Text('Material You'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.colour = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var sortDropdown = DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'App Sort By'),
|
||||||
|
value: settingsProvider.sortColumn,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortColumnSettings.authorName,
|
||||||
|
child: Text('Author/Name'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortColumnSettings.nameAuthor,
|
||||||
|
child: Text('Name/Author'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortColumnSettings.added,
|
||||||
|
child: Text('As Added'),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.sortColumn = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var orderDropdown = DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'App Sort Order'),
|
||||||
|
value: settingsProvider.sortOrder,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortOrderSettings.ascending,
|
||||||
|
child: Text('Ascending'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: SortOrderSettings.descending,
|
||||||
|
child: Text('Descending'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.sortOrder = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var intervalDropdown = DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Background Update Checking Interval'),
|
||||||
|
value: settingsProvider.updateInterval,
|
||||||
|
items: updateIntervals.map((e) {
|
||||||
|
int displayNum = (e < 60
|
||||||
|
? e
|
||||||
|
: e < 1440
|
||||||
|
? e / 60
|
||||||
|
: e / 1440)
|
||||||
|
.round();
|
||||||
|
var displayUnit = (e < 60
|
||||||
|
? 'Minute'
|
||||||
|
: e < 1440
|
||||||
|
? 'Hour'
|
||||||
|
: 'Day');
|
||||||
|
|
||||||
|
String display = e == 0
|
||||||
|
? 'Never - Manual Only'
|
||||||
|
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
|
||||||
|
return DropdownMenuItem(value: e, child: Text(display));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.updateInterval = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||||
|
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
||||||
|
return GeneratedForm(
|
||||||
|
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
|
||||||
|
onValueChanges: (values, valid) {
|
||||||
|
if (valid) {
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
settingsProvider.setSettingString(
|
||||||
|
e.moreSourceSettingsFormItems[i].id, values[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultValues: e.moreSourceSettingsFormItems.map((e) {
|
||||||
|
return settingsProvider.getSettingString(e.id) ?? '';
|
||||||
|
}).toList());
|
||||||
|
} else {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const height16 = SizedBox(
|
||||||
|
height: 16,
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: CustomScrollView(slivers: <Widget>[
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
@ -38,112 +175,22 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField(
|
themeDropdown,
|
||||||
decoration:
|
height16,
|
||||||
const InputDecoration(labelText: 'Theme'),
|
colourDropdown,
|
||||||
value: settingsProvider.theme,
|
height16,
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ThemeSettings.dark,
|
|
||||||
child: Text('Dark'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ThemeSettings.light,
|
|
||||||
child: Text('Light'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ThemeSettings.system,
|
|
||||||
child: Text('Follow System'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.theme = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
DropdownButtonFormField(
|
|
||||||
decoration:
|
|
||||||
const InputDecoration(labelText: 'Colour'),
|
|
||||||
value: settingsProvider.colour,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ColourSettings.basic,
|
|
||||||
child: Text('Obtainium'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ColourSettings.materialYou,
|
|
||||||
child: Text('Material You'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.colour = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(child: sortDropdown),
|
||||||
child: DropdownButtonFormField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'App Sort By'),
|
|
||||||
value: settingsProvider.sortColumn,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value:
|
|
||||||
SortColumnSettings.authorName,
|
|
||||||
child: Text('Author/Name'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value:
|
|
||||||
SortColumnSettings.nameAuthor,
|
|
||||||
child: Text('Name/Author'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: SortColumnSettings.added,
|
|
||||||
child: Text('As Added'),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.sortColumn = value;
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: orderDropdown),
|
||||||
child: DropdownButtonFormField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'App Sort Order'),
|
|
||||||
value: settingsProvider.sortOrder,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: SortOrderSettings.ascending,
|
|
||||||
child: Text('Ascending'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: SortOrderSettings.descending,
|
|
||||||
child: Text('Descending'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.sortOrder = value;
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(
|
height16,
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@ -155,57 +202,28 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Pin Updates to Top of Apps View'),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.pinUpdates,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.pinUpdates = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
const Divider(
|
const Divider(
|
||||||
height: 16,
|
height: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
height16,
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
'Updates',
|
'Updates',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField(
|
intervalDropdown,
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText:
|
|
||||||
'Background Update Checking Interval'),
|
|
||||||
value: settingsProvider.updateInterval,
|
|
||||||
items: updateIntervals.map((e) {
|
|
||||||
int displayNum = (e < 60
|
|
||||||
? e
|
|
||||||
: e < 1440
|
|
||||||
? e / 60
|
|
||||||
: e / 1440)
|
|
||||||
.round();
|
|
||||||
var displayUnit = (e < 60
|
|
||||||
? 'Minute'
|
|
||||||
: e < 1440
|
|
||||||
? 'Hour'
|
|
||||||
: 'Day');
|
|
||||||
|
|
||||||
String display = e == 0
|
|
||||||
? 'Never - Manual Only'
|
|
||||||
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
|
|
||||||
return DropdownMenuItem(
|
|
||||||
value: e, child: Text(display));
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.updateInterval = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox(
|
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Longer intervals recommended for large App collections',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.labelMedium!
|
|
||||||
.merge(const TextStyle(
|
|
||||||
fontStyle: FontStyle.italic)),
|
|
||||||
),
|
|
||||||
const Divider(
|
const Divider(
|
||||||
height: 48,
|
height: 48,
|
||||||
),
|
),
|
||||||
@ -214,42 +232,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
...sourceProvider.sources.map((e) {
|
...sourceSpecificFields,
|
||||||
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
|
||||||
return GeneratedForm(
|
|
||||||
items: e.moreSourceSettingsFormItems
|
|
||||||
.map((e) => [e])
|
|
||||||
.toList(),
|
|
||||||
onValueChanges: (values, valid) {
|
|
||||||
if (valid) {
|
|
||||||
for (var i = 0;
|
|
||||||
i < values.length;
|
|
||||||
i++) {
|
|
||||||
settingsProvider.setSettingString(
|
|
||||||
e.moreSourceSettingsFormItems[i]
|
|
||||||
.id,
|
|
||||||
values[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
defaultValues:
|
|
||||||
e.moreSourceSettingsFormItems.map((e) {
|
|
||||||
return settingsProvider
|
|
||||||
.getSettingString(e.id) ??
|
|
||||||
'';
|
|
||||||
}).toList());
|
|
||||||
} else {
|
|
||||||
return Container();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
))),
|
))),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
height16,
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||||
@ -267,9 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(
|
height16,
|
||||||
height: 16,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -8,6 +8,7 @@ import 'dart:io';
|
|||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||||
import 'package:installed_apps/app_info.dart';
|
import 'package:installed_apps/app_info.dart';
|
||||||
import 'package:installed_apps/installed_apps.dart';
|
import 'package:installed_apps/installed_apps.dart';
|
||||||
@ -24,15 +25,15 @@ import 'package:http/http.dart';
|
|||||||
class AppInMemory {
|
class AppInMemory {
|
||||||
late App app;
|
late App app;
|
||||||
double? downloadProgress;
|
double? downloadProgress;
|
||||||
AppInfo? installedInfo; // Also indicates that an App is installed
|
AppInfo? installedInfo;
|
||||||
|
|
||||||
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
class DownloadedApp {
|
class DownloadedApk {
|
||||||
String appId;
|
String appId;
|
||||||
File file;
|
File file;
|
||||||
DownloadedApp(this.appId, this.file);
|
DownloadedApk(this.appId, this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppsProvider with ChangeNotifier {
|
class AppsProvider with ChangeNotifier {
|
||||||
@ -40,113 +41,117 @@ class AppsProvider with ChangeNotifier {
|
|||||||
Map<String, AppInMemory> apps = {};
|
Map<String, AppInMemory> apps = {};
|
||||||
bool loadingApps = false;
|
bool loadingApps = false;
|
||||||
bool gettingUpdates = false;
|
bool gettingUpdates = false;
|
||||||
|
bool forBGTask = false;
|
||||||
|
|
||||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||||
bool isForeground = true;
|
bool isForeground = true;
|
||||||
late Stream<FGBGType>? foregroundStream;
|
late Stream<FGBGType>? foregroundStream;
|
||||||
late StreamSubscription<FGBGType>? foregroundSubscription;
|
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider(
|
AppsProvider({this.forBGTask = false}) {
|
||||||
{bool shouldLoadApps = false,
|
// Many setup tasks should only be done in the foreground isolate
|
||||||
bool shouldCheckUpdatesAfterLoad = false,
|
if (!forBGTask) {
|
||||||
bool shouldDeleteAPKs = false}) {
|
|
||||||
if (shouldLoadApps) {
|
|
||||||
// Subscribe to changes in the app foreground status
|
// Subscribe to changes in the app foreground status
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
foregroundSubscription = foregroundStream?.listen((event) async {
|
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||||
isForeground = event == FGBGType.foreground;
|
isForeground = event == FGBGType.foreground;
|
||||||
if (isForeground) await loadApps();
|
if (isForeground) await loadApps();
|
||||||
});
|
});
|
||||||
loadApps().then((_) {
|
() async {
|
||||||
if (shouldDeleteAPKs) {
|
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||||
deleteSavedAPKs();
|
await loadApps();
|
||||||
}
|
// Delete existing APKs
|
||||||
if (shouldCheckUpdatesAfterLoad) {
|
(await getExternalStorageDirectory())
|
||||||
checkUpdates();
|
?.listSync()
|
||||||
}
|
.where((element) => element.path.endsWith('.apk'))
|
||||||
});
|
.forEach((apk) {
|
||||||
|
apk.delete();
|
||||||
|
});
|
||||||
|
}();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadApk(String apkUrl, String fileName, Function? onProgress,
|
downloadFile(String url, String fileName, Function? onProgress) async {
|
||||||
Function? urlModifier,
|
|
||||||
{bool useExistingIfExists = true}) async {
|
|
||||||
var destDir = (await getExternalStorageDirectory())!.path;
|
var destDir = (await getExternalStorageDirectory())!.path;
|
||||||
if (urlModifier != null) {
|
|
||||||
apkUrl = await urlModifier(apkUrl);
|
|
||||||
}
|
|
||||||
StreamedResponse response =
|
StreamedResponse response =
|
||||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
await Client().send(Request('GET', Uri.parse(url)));
|
||||||
File downloadFile = File('$destDir/$fileName.apk');
|
File downloadedFile = File('$destDir/$fileName');
|
||||||
var alreadyExists = downloadFile.existsSync();
|
|
||||||
if (!alreadyExists || !useExistingIfExists) {
|
|
||||||
if (alreadyExists) {
|
|
||||||
downloadFile.deleteSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
var length = response.contentLength;
|
if (downloadedFile.existsSync()) {
|
||||||
var received = 0;
|
downloadedFile.deleteSync();
|
||||||
double? progress;
|
}
|
||||||
var sink = downloadFile.openWrite();
|
var length = response.contentLength;
|
||||||
|
var received = 0;
|
||||||
|
double? progress;
|
||||||
|
var sink = downloadedFile.openWrite();
|
||||||
|
|
||||||
await response.stream.map((s) {
|
await response.stream.map((s) {
|
||||||
received += s.length;
|
received += s.length;
|
||||||
progress = (length != null ? received / length * 100 : 30);
|
progress = (length != null ? received / length * 100 : 30);
|
||||||
if (onProgress != null) {
|
|
||||||
onProgress(progress);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}).pipe(sink);
|
|
||||||
|
|
||||||
await sink.close();
|
|
||||||
progress = null;
|
|
||||||
if (onProgress != null) {
|
if (onProgress != null) {
|
||||||
onProgress(progress);
|
onProgress(progress);
|
||||||
}
|
}
|
||||||
|
return s;
|
||||||
|
}).pipe(sink);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
await sink.close();
|
||||||
downloadFile.deleteSync();
|
progress = null;
|
||||||
throw response.reasonPhrase ?? 'Unknown Error';
|
if (onProgress != null) {
|
||||||
}
|
onProgress(progress);
|
||||||
}
|
}
|
||||||
return downloadFile;
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
downloadedFile.deleteSync();
|
||||||
|
throw response.reasonPhrase ?? 'Unknown Error';
|
||||||
|
}
|
||||||
|
return downloadedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Downloads the App (preferred URL) and returns an ApkFile object
|
Future<DownloadedApk> downloadApp(App app) async {
|
||||||
// If the app was already saved, updates it's download progress % in memory
|
var fileName =
|
||||||
// But also works for Apps that are not saved
|
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||||
Future<DownloadedApp> downloadApp(App app) async {
|
String downloadUrl = await SourceProvider()
|
||||||
var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}';
|
.getSource(app.url)
|
||||||
File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex],
|
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}',
|
int? prevProg;
|
||||||
(double? progress) {
|
File downloadedFile =
|
||||||
|
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||||
|
int? prog = progress?.ceil();
|
||||||
if (apps[app.id] != null) {
|
if (apps[app.id] != null) {
|
||||||
apps[app.id]!.downloadProgress = progress;
|
apps[app.id]!.downloadProgress = progress;
|
||||||
|
notifyListeners();
|
||||||
|
} else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
prevProg = prog;
|
||||||
}, SourceProvider().getSource(app.url).apkUrlPrefetchModifier);
|
});
|
||||||
// Delete older versions of the APK if any
|
// Delete older versions of the APK if any
|
||||||
for (var file in downloadFile.parent.listSync()) {
|
for (var file in downloadedFile.parent.listSync()) {
|
||||||
var fn = file.path.split('/').last;
|
var fn = file.path.split('/').last;
|
||||||
if (fn.startsWith('${app.id}-') &&
|
if (fn.startsWith('${app.id}-') &&
|
||||||
fn.endsWith('.apk') &&
|
fn.endsWith('.apk') &&
|
||||||
fn != '$fileName.apk') {
|
fn != fileName) {
|
||||||
file.delete();
|
file.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If the ID has changed (as it should on first download), replace it
|
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||||
var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path);
|
// 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 (app.id != newInfo.packageName) {
|
||||||
|
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||||
|
throw IDChangedError();
|
||||||
|
}
|
||||||
var originalAppId = app.id;
|
var originalAppId = app.id;
|
||||||
app.id = newInfo.packageName;
|
app.id = newInfo.packageName;
|
||||||
downloadFile = downloadFile.renameSync(
|
downloadedFile = downloadedFile.renameSync(
|
||||||
'${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||||
if (apps[originalAppId] != null) {
|
if (apps[originalAppId] != null) {
|
||||||
await removeApps([originalAppId]);
|
await removeApps([originalAppId]);
|
||||||
await saveApps([app]);
|
await saveApps([app]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return DownloadedApp(app.id, downloadFile);
|
return DownloadedApk(app.id, downloadedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool areDownloadsRunning() => apps.values
|
bool areDownloadsRunning() => apps.values
|
||||||
@ -154,24 +159,26 @@ class AppsProvider with ChangeNotifier {
|
|||||||
.isNotEmpty;
|
.isNotEmpty;
|
||||||
|
|
||||||
Future<bool> canInstallSilently(App app) async {
|
Future<bool> canInstallSilently(App app) async {
|
||||||
// TODO: This is unreliable - try to get from OS in the future
|
return false;
|
||||||
var osInfo = await DeviceInfoPlugin().androidInfo;
|
// TODO: Uncomment the below once silentupdates are ever figured out
|
||||||
return app.installedVersion != null &&
|
// // TODO: This is unreliable - try to get from OS in the future
|
||||||
osInfo.version.sdkInt >= 30 &&
|
// if (app.apkUrls.length > 1) {
|
||||||
osInfo.version.release.compareTo('12') >= 0;
|
// return false;
|
||||||
|
// }
|
||||||
|
// var osInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
// return app.installedVersion != null &&
|
||||||
|
// osInfo.version.sdkInt >= 30 &&
|
||||||
|
// osInfo.version.release.compareTo('12') >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> askUserToReturnToForeground(BuildContext context,
|
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
|
||||||
{bool waitForFG = false}) async {
|
|
||||||
NotificationsProvider notificationsProvider =
|
NotificationsProvider notificationsProvider =
|
||||||
context.read<NotificationsProvider>();
|
context.read<NotificationsProvider>();
|
||||||
if (!isForeground) {
|
if (!isForeground) {
|
||||||
await notificationsProvider.notify(completeInstallationNotification,
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
cancelExisting: true);
|
cancelExisting: true);
|
||||||
if (waitForFG) {
|
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
|
||||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,7 +186,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||||
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
|
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
|
||||||
// But even then, we don't know if it actually succeeded
|
// But even then, we don't know if it actually succeeded
|
||||||
Future<void> installApk(DownloadedApp file) async {
|
Future<void> installApk(DownloadedApk file) async {
|
||||||
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
|
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
|
||||||
AppInfo? appInfo;
|
AppInfo? appInfo;
|
||||||
try {
|
try {
|
||||||
@ -189,7 +196,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
if (appInfo != null &&
|
if (appInfo != null &&
|
||||||
int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
|
int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
|
||||||
throw 'Can\'t install an older version';
|
throw DowngradeError();
|
||||||
}
|
}
|
||||||
if (appInfo == null ||
|
if (appInfo == null ||
|
||||||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
||||||
@ -198,17 +205,25 @@ class AppsProvider with ChangeNotifier {
|
|||||||
apps[file.appId]!.app.installedVersion =
|
apps[file.appId]!.app.installedVersion =
|
||||||
apps[file.appId]!.app.latestVersion;
|
apps[file.appId]!.app.latestVersion;
|
||||||
// Don't correct install status as installation may not be done yet
|
// Don't correct install status as installation may not be done yet
|
||||||
await saveApps([apps[file.appId]!.app], shouldCorrectInstallStatus: false);
|
await saveApps([apps[file.appId]!.app],
|
||||||
|
attemptToCorrectInstallStatus: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> selectApkUrl(App app, BuildContext? context) async {
|
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
|
||||||
// If the App has more than one APK, the user should pick one (if context provided)
|
// If the App has more than one APK, the user should pick one (if context provided)
|
||||||
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
||||||
|
// get device supported architecture
|
||||||
|
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||||
|
|
||||||
if (app.apkUrls.length > 1 && context != null) {
|
if (app.apkUrls.length > 1 && context != null) {
|
||||||
apkUrl = await showDialog(
|
apkUrl = await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return APKPicker(app: app, initVal: apkUrl);
|
return APKPicker(
|
||||||
|
app: app,
|
||||||
|
initVal: apkUrl,
|
||||||
|
archs: archs,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||||
@ -228,15 +243,6 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return apkUrl;
|
return apkUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, List<String>> addToErrorMap(
|
|
||||||
Map<String, List<String>> errors, String appId, String error) {
|
|
||||||
var tempIds = errors.remove(error);
|
|
||||||
tempIds ??= [];
|
|
||||||
tempIds.add(appId);
|
|
||||||
errors.putIfAbsent(error, () => tempIds!);
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
|
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
|
||||||
// If the APKs can be installed silently, they are
|
// If the APKs can be installed silently, they are
|
||||||
// If no BuildContext is provided, apps that require user interaction are ignored
|
// If no BuildContext is provided, apps that require user interaction are ignored
|
||||||
@ -245,42 +251,41 @@ class AppsProvider with ChangeNotifier {
|
|||||||
Future<List<String>> downloadAndInstallLatestApps(
|
Future<List<String>> downloadAndInstallLatestApps(
|
||||||
List<String> appIds, BuildContext? context) async {
|
List<String> appIds, BuildContext? context) async {
|
||||||
List<String> appsToInstall = [];
|
List<String> appsToInstall = [];
|
||||||
|
// For all specified Apps, filter out those for which:
|
||||||
|
// 1. A URL cannot be picked
|
||||||
|
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||||
for (var id in appIds) {
|
for (var id in appIds) {
|
||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw 'App not found';
|
throw ObtainiumError('App not found');
|
||||||
}
|
}
|
||||||
|
String? apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||||
String? apkUrl = await selectApkUrl(apps[id]!.app, context);
|
|
||||||
|
|
||||||
if (apkUrl != null) {
|
if (apkUrl != null) {
|
||||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
apps[id]!.app.preferredApkIndex = urlInd;
|
apps[id]!.app.preferredApkIndex = urlInd;
|
||||||
await saveApps([apps[id]!.app]);
|
await saveApps([apps[id]!.app]);
|
||||||
}
|
}
|
||||||
if (context != null ||
|
if (context != null || await canInstallSilently(apps[id]!.app)) {
|
||||||
(await canInstallSilently(apps[id]!.app) &&
|
|
||||||
apps[id]!.app.apkUrls.length == 1)) {
|
|
||||||
appsToInstall.add(id);
|
appsToInstall.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Map<String, List<String>> errors = {};
|
// Download APKs for all Apps to be installed
|
||||||
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
List<DownloadedApp?> downloadedFiles =
|
List<DownloadedApk?> downloadedFiles =
|
||||||
await Future.wait(appsToInstall.map((id) async {
|
await Future.wait(appsToInstall.map((id) async {
|
||||||
try {
|
try {
|
||||||
return await downloadApp(apps[id]!.app);
|
return await downloadApp(apps[id]!.app);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToErrorMap(errors, id, e.toString());
|
errors.add(id, e.toString());
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}));
|
}));
|
||||||
downloadedFiles =
|
downloadedFiles =
|
||||||
downloadedFiles.where((element) => element != null).toList();
|
downloadedFiles.where((element) => element != null).toList();
|
||||||
|
// Separate the Apps to install into silent and regular lists
|
||||||
List<DownloadedApp> silentUpdates = [];
|
List<DownloadedApk> silentUpdates = [];
|
||||||
List<DownloadedApp> regularInstalls = [];
|
List<DownloadedApk> regularInstalls = [];
|
||||||
for (var f in downloadedFiles) {
|
for (var f in downloadedFiles) {
|
||||||
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||||
if (willBeSilent) {
|
if (willBeSilent) {
|
||||||
@ -290,10 +295,13 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move everything to the regular install list (since silent updates don't currently work) - TODO
|
||||||
|
regularInstalls.addAll(silentUpdates);
|
||||||
|
|
||||||
// If Obtainium is being installed, it should be the last one
|
// If Obtainium is being installed, it should be the last one
|
||||||
List<DownloadedApp> moveObtainiumToEnd(List<DownloadedApp> items) {
|
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||||
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
||||||
DownloadedApp? temp;
|
DownloadedApk? temp;
|
||||||
items.removeWhere((element) {
|
items.removeWhere((element) {
|
||||||
bool res = element.appId == obtainiumId;
|
bool res = element.appId == obtainiumId;
|
||||||
if (res) {
|
if (res) {
|
||||||
@ -302,44 +310,38 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
if (temp != null) {
|
if (temp != null) {
|
||||||
items.add(temp!);
|
items = [temp!, ...items];
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove below line if silentupdates are ever figured out
|
silentUpdates = moveObtainiumToStart(silentUpdates);
|
||||||
regularInstalls.addAll(silentUpdates);
|
regularInstalls = moveObtainiumToStart(regularInstalls);
|
||||||
|
|
||||||
silentUpdates = moveObtainiumToEnd(silentUpdates);
|
// // Install silent updates (uncomment when it works - TODO)
|
||||||
regularInstalls = moveObtainiumToEnd(regularInstalls);
|
|
||||||
|
|
||||||
// TODO: Uncomment below if silentupdates are ever figured out
|
|
||||||
// for (var u in silentUpdates) {
|
// for (var u in silentUpdates) {
|
||||||
// await installApk(u, silent: true); // Would need to add silent option
|
// await installApk(u, silent: true); // Would need to add silent option
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if (context != null) {
|
// Do regular installs
|
||||||
if (regularInstalls.isNotEmpty) {
|
if (regularInstalls.isNotEmpty && context != null) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
await askUserToReturnToForeground(context, waitForFG: true);
|
await waitForUserToReturnToForeground(context);
|
||||||
}
|
|
||||||
for (var i in regularInstalls) {
|
for (var i in regularInstalls) {
|
||||||
try {
|
try {
|
||||||
await installApk(i);
|
await installApk(i);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToErrorMap(errors, i.appId, e.toString());
|
errors.add(i.appId, e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (errors.isNotEmpty) {
|
|
||||||
String finalError = '';
|
if (errors.content.isNotEmpty) {
|
||||||
for (var e in errors.keys) {
|
throw errors;
|
||||||
finalError +=
|
|
||||||
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
|
|
||||||
}
|
|
||||||
throw finalError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||||
|
|
||||||
return downloadedFiles.map((e) => e!.appId).toList();
|
return downloadedFiles.map((e) => e!.appId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,40 +354,6 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return appsDir;
|
return appsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all stored APKs except those likely to still be needed
|
|
||||||
Future<void> deleteSavedAPKs() async {
|
|
||||||
List<FileSystemEntity>? apks = (await getExternalStorageDirectory())
|
|
||||||
?.listSync()
|
|
||||||
.where((element) => element.path.endsWith('.apk'))
|
|
||||||
.toList();
|
|
||||||
if (apks != null && apks.isNotEmpty) {
|
|
||||||
for (var apk in apks) {
|
|
||||||
var shouldDelete = true;
|
|
||||||
var temp = apk.path.split('/').last;
|
|
||||||
temp = temp.substring(0, temp.length - 4);
|
|
||||||
var fn = temp.split('-');
|
|
||||||
if (fn.length == 3) {
|
|
||||||
var possibleId = fn[0];
|
|
||||||
var possibleVersion = fn[1];
|
|
||||||
var possibleApkUrlIndex = fn[2];
|
|
||||||
if (apps[possibleId] != null) {
|
|
||||||
if (apps[possibleId] != null &&
|
|
||||||
apps[possibleId]?.app != null &&
|
|
||||||
apps[possibleId]!.app.installedVersion !=
|
|
||||||
apps[possibleId]!.app.latestVersion &&
|
|
||||||
apps[possibleId]!.app.latestVersion == possibleVersion &&
|
|
||||||
apps[possibleId]!.app.preferredApkIndex.toString() ==
|
|
||||||
possibleApkUrlIndex) {
|
|
||||||
shouldDelete = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldDelete) apk.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||||
if (packageName != null) {
|
if (packageName != null) {
|
||||||
try {
|
try {
|
||||||
@ -397,24 +365,37 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String standardizeVersionString(String versionString) {
|
// If the App says it is installed but installedInfo is null, set it to not installed
|
||||||
return versionString.characters
|
|
||||||
.where((p0) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.']
|
|
||||||
.contains(p0))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the App says it is installed by installedInfo is null, set it to not installed
|
|
||||||
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
|
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
|
||||||
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
|
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
|
||||||
App? correctInstallStatus(App app, AppInfo? installedInfo) {
|
// 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)
|
||||||
|
// If in a background isolate, return null straight away as the required plugin won't work anyways
|
||||||
|
App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) {
|
||||||
|
if (forBGTask) {
|
||||||
|
return null; // Can't correct in the background isolate
|
||||||
|
}
|
||||||
var modded = false;
|
var modded = false;
|
||||||
if (installedInfo == null && app.installedVersion != null) {
|
if (installedInfo == null && app.installedVersion != null) {
|
||||||
app.installedVersion = null;
|
app.installedVersion = null;
|
||||||
modded = true;
|
modded = true;
|
||||||
}
|
}
|
||||||
if (installedInfo != null && app.installedVersion == null) {
|
if (installedInfo != null && app.installedVersion == null) {
|
||||||
if (standardizeVersionString(app.latestVersion) ==
|
if (app.latestVersion.characters
|
||||||
|
.where((p0) => [
|
||||||
|
'0',
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
'3',
|
||||||
|
'4',
|
||||||
|
'5',
|
||||||
|
'6',
|
||||||
|
'7',
|
||||||
|
'8',
|
||||||
|
'9',
|
||||||
|
'.'
|
||||||
|
].contains(p0))
|
||||||
|
.join('') ==
|
||||||
installedInfo.versionName) {
|
installedInfo.versionName) {
|
||||||
app.installedVersion = app.latestVersion;
|
app.installedVersion = app.latestVersion;
|
||||||
} else {
|
} else {
|
||||||
@ -425,7 +406,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return modded ? app : null;
|
return modded ? app : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadApps({shouldCorrectInstallStatus = true}) async {
|
Future<void> loadApps() async {
|
||||||
while (loadingApps) {
|
while (loadingApps) {
|
||||||
await Future.delayed(const Duration(microseconds: 1));
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
}
|
}
|
||||||
@ -456,28 +437,26 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
loadingApps = false;
|
loadingApps = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
// For any that are not installed (by ID == package name), set to not installed if needed
|
List<App> modifiedApps = [];
|
||||||
if (shouldCorrectInstallStatus) {
|
for (var app in apps.values) {
|
||||||
List<App> modifiedApps = [];
|
var moddedApp =
|
||||||
for (var app in apps.values) {
|
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
|
||||||
var moddedApp = correctInstallStatus(app.app, app.installedInfo);
|
if (moddedApp != null) {
|
||||||
if (moddedApp != null) {
|
modifiedApps.add(moddedApp);
|
||||||
modifiedApps.add(moddedApp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (modifiedApps.isNotEmpty) {
|
|
||||||
await saveApps(modifiedApps, shouldCorrectInstallStatus: false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (modifiedApps.isNotEmpty) {
|
||||||
|
await saveApps(modifiedApps);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveApps(List<App> apps,
|
Future<void> saveApps(List<App> apps,
|
||||||
{bool shouldCorrectInstallStatus = true}) async {
|
{bool attemptToCorrectInstallStatus = true}) async {
|
||||||
for (var app in apps) {
|
for (var app in apps) {
|
||||||
AppInfo? info = await getInstalledInfo(app.id);
|
AppInfo? info = await getInstalledInfo(app.id);
|
||||||
app.name = info?.name ?? app.name;
|
app.name = info?.name ?? app.name;
|
||||||
if (shouldCorrectInstallStatus) {
|
if (attemptToCorrectInstallStatus) {
|
||||||
app = correctInstallStatus(app, info) ?? app;
|
app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
|
||||||
}
|
}
|
||||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
File('${(await getAppsDir()).path}/${app.id}.json')
|
||||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
.writeAsStringSync(jsonEncode(app.toJson()));
|
||||||
@ -503,15 +482,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool checkAppObjectForUpdate(App app) {
|
Future<App?> checkUpdate(String appId) async {
|
||||||
if (!apps.containsKey(app.id)) {
|
|
||||||
throw 'App not found';
|
|
||||||
}
|
|
||||||
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<App?> getUpdate(String appId,
|
|
||||||
{bool shouldCorrectInstallStatus = true}) async {
|
|
||||||
App? currentApp = apps[appId]!.app;
|
App? currentApp = apps[appId]!.app;
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
App newApp = await sourceProvider.getApp(
|
App newApp = await sourceProvider.getApp(
|
||||||
@ -524,51 +495,39 @@ class AppsProvider with ChangeNotifier {
|
|||||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||||
}
|
}
|
||||||
await saveApps([newApp],
|
await saveApps([newApp]);
|
||||||
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
|
|
||||||
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<App>> checkUpdates(
|
Future<List<App>> checkUpdates(
|
||||||
{DateTime? ignoreAfter,
|
{DateTime? ignoreAppsCheckedAfter,
|
||||||
bool immediatelyThrowRateLimitError = false,
|
bool throwErrorsForRetry = false}) async {
|
||||||
bool shouldCorrectInstallStatus = true,
|
|
||||||
bool immediatelyThrowSocketError = false}) async {
|
|
||||||
List<App> updates = [];
|
List<App> updates = [];
|
||||||
Map<String, List<String>> errors = {};
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
if (!gettingUpdates) {
|
if (!gettingUpdates) {
|
||||||
gettingUpdates = true;
|
gettingUpdates = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<String> appIds = apps.keys.toList();
|
List<String> appIds = apps.values
|
||||||
if (ignoreAfter != null) {
|
.where((app) =>
|
||||||
appIds = appIds
|
app.app.lastUpdateCheck == null ||
|
||||||
.where((id) =>
|
ignoreAppsCheckedAfter == null ||
|
||||||
apps[id]!.app.lastUpdateCheck == null ||
|
app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
|
||||||
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
|
.map((e) => e.app.id)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
|
||||||
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
|
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
|
||||||
DateTime.fromMicrosecondsSinceEpoch(0))
|
DateTime.fromMicrosecondsSinceEpoch(0))
|
||||||
.compareTo(apps[b]!.app.lastUpdateCheck ??
|
.compareTo(apps[b]!.app.lastUpdateCheck ??
|
||||||
DateTime.fromMicrosecondsSinceEpoch(0)));
|
DateTime.fromMicrosecondsSinceEpoch(0)));
|
||||||
|
|
||||||
for (int i = 0; i < appIds.length; i++) {
|
for (int i = 0; i < appIds.length; i++) {
|
||||||
App? newApp;
|
App? newApp;
|
||||||
try {
|
try {
|
||||||
newApp = await getUpdate(appIds[i],
|
newApp = await checkUpdate(appIds[i]);
|
||||||
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is RateLimitError && immediatelyThrowRateLimitError) {
|
if ((e is RateLimitError || e is SocketException) &&
|
||||||
|
throwErrorsForRetry) {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
if (e is SocketException && immediatelyThrowSocketError) {
|
errors.add(appIds[i], e.toString());
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
var tempIds = errors.remove(e.toString());
|
|
||||||
tempIds ??= [];
|
|
||||||
tempIds.add(appIds[i]);
|
|
||||||
errors.putIfAbsent(e.toString(), () => tempIds!);
|
|
||||||
}
|
}
|
||||||
if (newApp != null) {
|
if (newApp != null) {
|
||||||
updates.add(newApp);
|
updates.add(newApp);
|
||||||
@ -578,18 +537,13 @@ class AppsProvider with ChangeNotifier {
|
|||||||
gettingUpdates = false;
|
gettingUpdates = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (errors.isNotEmpty) {
|
if (errors.content.isNotEmpty) {
|
||||||
String finalError = '';
|
throw errors;
|
||||||
for (var e in errors.keys) {
|
|
||||||
finalError +=
|
|
||||||
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
|
|
||||||
}
|
|
||||||
throw finalError;
|
|
||||||
}
|
}
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getExistingUpdates(
|
List<String> findExistingUpdates(
|
||||||
{bool installedOnly = false, bool nonInstalledOnly = false}) {
|
{bool installedOnly = false, bool nonInstalledOnly = false}) {
|
||||||
List<String> updateAppIds = [];
|
List<String> updateAppIds = [];
|
||||||
List<String> appIds = apps.keys.toList();
|
List<String> appIds = apps.keys.toList();
|
||||||
@ -623,7 +577,6 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> importApps(String appsJSON) async {
|
Future<int> importApps(String appsJSON) async {
|
||||||
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
|
|
||||||
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
||||||
.map((e) => App.fromJson(e))
|
.map((e) => App.fromJson(e))
|
||||||
.toList();
|
.toList();
|
||||||
@ -648,10 +601,11 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class APKPicker extends StatefulWidget {
|
class APKPicker extends StatefulWidget {
|
||||||
const APKPicker({super.key, required this.app, this.initVal});
|
const APKPicker({super.key, required this.app, this.initVal, this.archs});
|
||||||
|
|
||||||
final App app;
|
final App app;
|
||||||
final String? initVal;
|
final String? initVal;
|
||||||
|
final List<String>? archs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<APKPicker> createState() => _APKPickerState();
|
State<APKPicker> createState() => _APKPickerState();
|
||||||
@ -669,18 +623,29 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
Text('${widget.app.name} has more than one package:'),
|
Text('${widget.app.name} has more than one package:'),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
...widget.app.apkUrls.map(
|
||||||
title: Text(Uri.parse(u)
|
(u) => RadioListTile<String>(
|
||||||
.pathSegments
|
title: Text(Uri.parse(u)
|
||||||
.where((element) => element.isNotEmpty)
|
.pathSegments
|
||||||
.last),
|
.where((element) => element.isNotEmpty)
|
||||||
value: u,
|
.last),
|
||||||
groupValue: apkUrl,
|
value: u,
|
||||||
onChanged: (String? val) {
|
groupValue: apkUrl,
|
||||||
setState(() {
|
onChanged: (String? val) {
|
||||||
apkUrl = val;
|
setState(() {
|
||||||
});
|
apkUrl = val;
|
||||||
}))
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (widget.archs != null)
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
if (widget.archs != null)
|
||||||
|
Text(
|
||||||
|
'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}',
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
@ -27,9 +27,11 @@ class UpdateNotification extends ObtainiumNotification {
|
|||||||
'Updates Available',
|
'Updates Available',
|
||||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
||||||
Importance.max) {
|
Importance.max) {
|
||||||
message = updates.length == 1
|
message = updates.isEmpty
|
||||||
? '${updates[0].name} has an update.'
|
? "No new updates."
|
||||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
: updates.length == 1
|
||||||
|
? '${updates[0].name} has an update.'
|
||||||
|
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int get updateInterval {
|
int get updateInterval {
|
||||||
var min = prefs?.getInt('updateInterval') ?? 180;
|
var min = prefs?.getInt('updateInterval') ?? 360;
|
||||||
if (!updateIntervals.contains(min)) {
|
if (!updateIntervals.contains(min)) {
|
||||||
var temp = updateIntervals[0];
|
var temp = updateIntervals[0];
|
||||||
for (var i in updateIntervals) {
|
for (var i in updateIntervals) {
|
||||||
@ -123,6 +123,15 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get pinUpdates {
|
||||||
|
return prefs?.getBool('pinUpdates') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set pinUpdates(bool show) {
|
||||||
|
prefs?.setBool('pinUpdates', show);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
String? getSettingString(String settingId) {
|
String? getSettingString(String settingId) {
|
||||||
return prefs?.getString(settingId);
|
return prefs?.getString(settingId);
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import 'package:obtainium/app_sources/mullvad.dart';
|
|||||||
import 'package:obtainium/app_sources/signal.dart';
|
import 'package:obtainium/app_sources/signal.dart';
|
||||||
import 'package:obtainium/app_sources/sourceforge.dart';
|
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||||
import 'package:obtainium/components/generated_form.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||||
|
|
||||||
class AppNames {
|
class AppNames {
|
||||||
@ -90,12 +91,7 @@ class App {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeRegEx(String s) {
|
// Ensure the input is starts with HTTPS and has no WWW
|
||||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
|
||||||
return '\\${x[0]}';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
preStandardizeUrl(String url) {
|
preStandardizeUrl(String url) {
|
||||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||||
url.toLowerCase().indexOf('https://') != 0) {
|
url.toLowerCase().indexOf('https://') != 0) {
|
||||||
@ -104,6 +100,11 @@ preStandardizeUrl(String url) {
|
|||||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
||||||
url = 'https://${url.substring(12)}';
|
url = 'https://${url.substring(12)}';
|
||||||
}
|
}
|
||||||
|
url = url
|
||||||
|
.split('/')
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.join('/')
|
||||||
|
.replaceFirst(':/', '://');
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,7 +141,7 @@ abstract class AppSource {
|
|||||||
Future<String> apkUrlPrefetchModifier(String apkUrl);
|
Future<String> apkUrlPrefetchModifier(String apkUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class MassAppSource {
|
abstract class MassAppUrlSource {
|
||||||
late String name;
|
late String name;
|
||||||
late List<String> requiredArgs;
|
late List<String> requiredArgs;
|
||||||
Future<List<String>> getUrls(List<String> args);
|
Future<List<String>> getUrls(List<String> args);
|
||||||
@ -155,12 +156,11 @@ class SourceProvider {
|
|||||||
IzzyOnDroid(),
|
IzzyOnDroid(),
|
||||||
Mullvad(),
|
Mullvad(),
|
||||||
Signal(),
|
Signal(),
|
||||||
SourceForge(),
|
SourceForge()
|
||||||
// APKMirror()
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add more mass source classes here so they are available via the service
|
// Add more mass url source classes here so they are available via the service
|
||||||
List<MassAppSource> massSources = [GitHubStars()];
|
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
|
||||||
|
|
||||||
AppSource getSource(String url) {
|
AppSource getSource(String url) {
|
||||||
url = preStandardizeUrl(url);
|
url = preStandardizeUrl(url);
|
||||||
@ -172,12 +172,12 @@ class SourceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
throw 'URL does not match a known source';
|
throw UnsupportedURLError();
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool doesSourceHaveRequiredAdditionalData(AppSource source) {
|
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||||
for (var row in source.additionalDataFormItems) {
|
for (var row in source.additionalDataFormItems) {
|
||||||
for (var element in row) {
|
for (var element in row) {
|
||||||
if (element.required) {
|
if (element.required) {
|
||||||
@ -191,6 +191,19 @@ class SourceProvider {
|
|||||||
String generateTempID(AppNames names, AppSource source) =>
|
String generateTempID(AppNames names, AppSource source) =>
|
||||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
||||||
|
|
||||||
|
bool isTempId(String id) {
|
||||||
|
List<String> parts = id.split('_');
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (RegExp('.*[A-Z].*').hasMatch(parts[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getSourceHosts().contains(parts.last);
|
||||||
|
}
|
||||||
|
|
||||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||||
{String name = '', String? id}) async {
|
{String name = '', String? id}) async {
|
||||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||||
@ -205,15 +218,14 @@ class SourceProvider {
|
|||||||
? name
|
? name
|
||||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||||
null,
|
null,
|
||||||
apk.version,
|
apk.version.replaceAll('/', '-'),
|
||||||
apk.apkUrls,
|
apk.apkUrls,
|
||||||
apk.apkUrls.length - 1,
|
apk.apkUrls.length - 1,
|
||||||
additionalData,
|
additionalData,
|
||||||
DateTime.now());
|
DateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a length 2 list, where the first element is a list of Apps and
|
// Returns errors in [results, errors] instead of throwing them
|
||||||
/// the second is a Map<String, dynamic> of URLs and errors
|
|
||||||
Future<List<dynamic>> getApps(List<String> urls,
|
Future<List<dynamic>> getApps(List<String> urls,
|
||||||
{List<String> ignoreUrls = const []}) async {
|
{List<String> ignoreUrls = const []}) async {
|
||||||
List<App> apps = [];
|
List<App> apps = [];
|
||||||
|
18
pubspec.lock
18
pubspec.lock
@ -1,6 +1,13 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
android_alarm_manager_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: android_alarm_manager_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
animations:
|
animations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -358,7 +365,7 @@ packages:
|
|||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.20"
|
version: "2.0.21"
|
||||||
path_provider_ios:
|
path_provider_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -470,7 +477,7 @@ packages:
|
|||||||
name: share_plus
|
name: share_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.2.0"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -700,13 +707,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.1"
|
||||||
workmanager:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: workmanager
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "0.5.1"
|
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.6.0+44 # When changing this, update the tag in main() accordingly
|
version: 0.6.11+55 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.18.2 <3.0.0'
|
sdk: '>=2.18.2 <3.0.0'
|
||||||
@ -42,7 +42,6 @@ dependencies:
|
|||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
webview_flutter: ^3.0.4
|
webview_flutter: ^3.0.4
|
||||||
workmanager: ^0.5.0
|
|
||||||
dynamic_color: ^1.5.4
|
dynamic_color: ^1.5.4
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
shared_preferences: ^2.0.15
|
shared_preferences: ^2.0.15
|
||||||
@ -56,6 +55,7 @@ dependencies:
|
|||||||
share_plus: ^6.0.1
|
share_plus: ^6.0.1
|
||||||
installed_apps: ^1.3.1
|
installed_apps: ^1.3.1
|
||||||
package_archive_info: ^0.1.0
|
package_archive_info: ^0.1.0
|
||||||
|
android_alarm_manager_plus: ^2.1.0
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
Reference in New Issue
Block a user