Compare commits
40 Commits
v0.5.8-bet
...
v0.7.0-bet
Author | SHA1 | Date | |
---|---|---|---|
55cae0620b | |||
ba6cea3ae6 | |||
4be33374c2 | |||
e2bf834981 | |||
9bd7ddb21b | |||
905a807ee9 | |||
ab57b97875 | |||
5db2c5f0b1 | |||
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 | |||
d1a3529036 | |||
a954a627fd | |||
52ce5b19c4 |
@ -13,7 +13,6 @@ Currently supported App sources:
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
- [APKMirror](https://apkmirror.com/)
|
||||
|
||||
## Limitations
|
||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
|
@ -30,7 +30,25 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
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>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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>
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 228 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 162 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 170 KiB |
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 188 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
@ -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,11 +1,12 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class FDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'f-droid.org';
|
||||
class FDroid extends AppSource {
|
||||
FDroid() {
|
||||
host = 'f-droid.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -18,7 +19,7 @@ class FDroid implements AppSource {
|
||||
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
||||
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -34,25 +35,41 @@ class FDroid implements AppSource {
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var latestReleaseDiv =
|
||||
parse(res.body).querySelector('#latest.package-version');
|
||||
var apkUrl = latestReleaseDiv
|
||||
?.querySelector('.package-version-download a')
|
||||
?.attributes['href'];
|
||||
if (apkUrl == null) {
|
||||
throw noAPKFound;
|
||||
var releases = parse(res.body).querySelectorAll('.package-version');
|
||||
if (releases.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
var version = latestReleaseDiv
|
||||
?.querySelector('.package-version-header b')
|
||||
String? latestVersion = releases[0]
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
.sublist(1)
|
||||
.join(' ');
|
||||
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 {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,13 +77,4 @@ class FDroid implements AppSource {
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -7,16 +7,88 @@ import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class GitHub implements AppSource {
|
||||
@override
|
||||
late String host = 'github.com';
|
||||
class GitHub extends AppSource {
|
||||
GitHub() {
|
||||
host = 'github.com';
|
||||
|
||||
additionalDataDefaults = ['true', 'true', ''];
|
||||
|
||||
moreSourceSettingsFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
||||
id: 'github-creds',
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value != null && value.trim().isNotEmpty) {
|
||||
if (value
|
||||
.split(':')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.length !=
|
||||
2) {
|
||||
return 'PAT must be in this format: username:token';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
],
|
||||
hint: 'username:token',
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: const Text(
|
||||
'About GitHub PATs',
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
])
|
||||
];
|
||||
|
||||
additionalDataFormItems = [
|
||||
[
|
||||
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Fallback to older releases', type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Filter Release Titles by Regular Expression',
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return 'Invalid regular expression';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
canSearch = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -69,6 +141,7 @@ class GitHub implements AppSource {
|
||||
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (regexFilter != null &&
|
||||
!RegExp(regexFilter)
|
||||
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||
@ -83,14 +156,14 @@ class GitHub implements AppSource {
|
||||
break;
|
||||
}
|
||||
if (targetRelease == null) {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||
throw noAPKFound;
|
||||
throw NoAPKError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls']);
|
||||
} else {
|
||||
@ -101,7 +174,7 @@ class GitHub implements AppSource {
|
||||
.round());
|
||||
}
|
||||
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,72 +186,23 @@ class GitHub implements AppSource {
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [
|
||||
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Fallback to older releases', type: FormItemType.bool)
|
||||
],
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Filter Release Titles by Regular Expression',
|
||||
type: FormItemType.string,
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
RegExp(value);
|
||||
} catch (e) {
|
||||
return 'Invalid regular expression';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
])
|
||||
]
|
||||
];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = ['true', 'true', ''];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
||||
id: 'github-creds',
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
if (value != null && value.trim().isNotEmpty) {
|
||||
if (value
|
||||
.split(':')
|
||||
.where((element) => element.trim().isNotEmpty)
|
||||
.length !=
|
||||
2) {
|
||||
return 'PAT must be in this format: username:token';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
],
|
||||
hint: 'username:token',
|
||||
belowWidgets: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: const Text(
|
||||
'About GitHub PATs',
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline, fontSize: 12),
|
||||
))
|
||||
])
|
||||
];
|
||||
Future<List<String>> search(String query) async {
|
||||
Response res = await get(Uri.parse(
|
||||
'https://${await getCredentialPrefixIfAny()}api.$host/search/repositories?q=${Uri.encodeQueryComponent(query)}&per_page=100'));
|
||||
if (res.statusCode == 200) {
|
||||
return (jsonDecode(res.body)['items'] as List<dynamic>)
|
||||
.map((e) => e['html_url'] as String)
|
||||
.toList();
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
throw ObtainiumError(
|
||||
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
|
||||
unexpected: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,20 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class GitLab implements AppSource {
|
||||
@override
|
||||
late String host = 'gitlab.com';
|
||||
class GitLab extends AppSource {
|
||||
GitLab() {
|
||||
host = 'gitlab.com';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -39,7 +40,9 @@ class GitLab implements AppSource {
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return '\\${x[0]}';
|
||||
})}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin),
|
||||
// GitLab releases may contain links to externally hosted APKs
|
||||
@ -49,18 +52,18 @@ class GitLab implements AppSource {
|
||||
.toList()
|
||||
];
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw noAPKFound;
|
||||
throw NoAPKError();
|
||||
}
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,13 +72,4 @@ class GitLab implements AppSource {
|
||||
// Same as GitHub
|
||||
return GitHub().getAppNames(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class IzzyOnDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'android.izzysoft.de';
|
||||
class IzzyOnDroid extends AppSource {
|
||||
IzzyOnDroid() {
|
||||
host = 'android.izzysoft.de';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -37,7 +38,7 @@ class IzzyOnDroid implements AppSource {
|
||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||
.toList();
|
||||
if (multipleVersionApkUrls.isEmpty) {
|
||||
throw noAPKFound;
|
||||
throw NoAPKError();
|
||||
}
|
||||
var version = parsedHtml
|
||||
.querySelector('#keydata')
|
||||
@ -50,11 +51,11 @@ class IzzyOnDroid implements AppSource {
|
||||
?.children[1]
|
||||
.innerHtml;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,13 +63,4 @@ class IzzyOnDroid implements AppSource {
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Mullvad implements AppSource {
|
||||
@override
|
||||
late String host = 'mullvad.net';
|
||||
class Mullvad extends AppSource {
|
||||
Mullvad() {
|
||||
host = 'mullvad.net';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -36,12 +37,12 @@ class Mullvad implements AppSource {
|
||||
?.split('/')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(
|
||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,13 +50,4 @@ class Mullvad implements AppSource {
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class Signal implements AppSource {
|
||||
@override
|
||||
late String host = 'signal.org';
|
||||
class Signal extends AppSource {
|
||||
Signal() {
|
||||
host = 'signal.org';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
@ -27,27 +28,18 @@ class Signal implements AppSource {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
if (apkUrl == null) {
|
||||
throw noAPKFound;
|
||||
throw NoAPKError();
|
||||
}
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -1,18 +1,19 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class SourceForge implements AppSource {
|
||||
@override
|
||||
late String host = 'sourceforge.net';
|
||||
class SourceForge extends AppSource {
|
||||
SourceForge() {
|
||||
host = 'sourceforge.net';
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL(runtimeType.toString());
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -42,7 +43,7 @@ class SourceForge implements AppSource {
|
||||
|
||||
String? version = getVersion(allDownloadLinks[0]);
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
throw NoVersionError();
|
||||
}
|
||||
var apkUrlListAllReleases = allDownloadLinks
|
||||
.where((element) => element.toLowerCase().endsWith('.apk/download'))
|
||||
@ -52,11 +53,11 @@ class SourceForge implements AppSource {
|
||||
.where((element) => getVersion(element) == version)
|
||||
.toList();
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw noAPKFound;
|
||||
throw NoAPKError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,13 +66,4 @@ class SourceForge implements AppSource {
|
||||
return AppNames(runtimeType.toString(),
|
||||
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
|
||||
}
|
||||
|
||||
@override
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
|
||||
@override
|
||||
List<String> additionalDataDefaults = [];
|
||||
|
||||
@override
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
values = widget.defaultValues;
|
||||
valid = widget.initValid;
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ObtainiumError {
|
||||
late String message;
|
||||
bool unexpected;
|
||||
ObtainiumError(this.message, {this.unexpected = false});
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError {
|
||||
late int remainingMinutes;
|
||||
RateLimitError(this.remainingMinutes);
|
||||
@ -6,3 +18,101 @@ class RateLimitError {
|
||||
String toString() =>
|
||||
'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 NotImplementedError extends ObtainiumError {
|
||||
NotImplementedError() : super('This class has not implemented this function');
|
||||
}
|
||||
|
||||
class MultiAppMultiError extends ObtainiumError {
|
||||
Map<String, List<String>> content = {};
|
||||
|
||||
MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true);
|
||||
|
||||
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.unexpected)) {
|
||||
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,6 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/home.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
@ -9,44 +11,52 @@ import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:dynamic_color/dynamic_color.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.7.0';
|
||||
const String currentReleaseTag =
|
||||
'v0.5.8-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();
|
||||
await AndroidAlarmManager.initialize();
|
||||
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||
: null;
|
||||
var notificationsProvider = NotificationsProvider();
|
||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||
try {
|
||||
var appsProvider = AppsProvider();
|
||||
var appsProvider = AppsProvider(forBGTask: true);
|
||||
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps();
|
||||
List<String> existingUpdateIds =
|
||||
appsProvider.getExistingUpdates(installedOnly: true);
|
||||
appsProvider.findExistingUpdates(installedOnly: true);
|
||||
DateTime nextIgnoreAfter = DateTime.now();
|
||||
String? err;
|
||||
try {
|
||||
await appsProvider.checkUpdates(ignoreAfter: ignoreAfter);
|
||||
await appsProvider.checkUpdates(
|
||||
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||
} catch (e) {
|
||||
if (e is RateLimitError) {
|
||||
String nextTaskName =
|
||||
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
|
||||
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
initialDelay: Duration(minutes: e.remainingMinutes),
|
||||
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
|
||||
if (e is RateLimitError || e is SocketException) {
|
||||
AndroidAlarmManager.oneShot(
|
||||
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
||||
Random().nextInt(pow(2, 31) as int),
|
||||
bgUpdateCheck,
|
||||
params: {
|
||||
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||
});
|
||||
} else {
|
||||
rethrow;
|
||||
err = e.toString();
|
||||
}
|
||||
}
|
||||
List<App> newUpdates = appsProvider
|
||||
.getExistingUpdates(installedOnly: true)
|
||||
.findExistingUpdates(installedOnly: true)
|
||||
.where((id) => !existingUpdateIds.contains(id))
|
||||
.map((e) => appsProvider.apps[e]!.app)
|
||||
.toList();
|
||||
@ -66,45 +76,31 @@ bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
||||
// }
|
||||
|
||||
if (newUpdates.isNotEmpty) {
|
||||
notificationsProvider.notify(UpdateNotification(newUpdates),
|
||||
cancelExisting: true);
|
||||
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||
}
|
||||
if (err != null) {
|
||||
throw err;
|
||||
}
|
||||
return Future.value(true);
|
||||
} catch (e) {
|
||||
notificationsProvider.notify(ErrorCheckingUpdatesNotification(e.toString()),
|
||||
cancelExisting: true);
|
||||
return Future.error(false);
|
||||
notificationsProvider
|
||||
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||
} finally {
|
||||
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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
Workmanager().initialize(
|
||||
bgTaskCallback,
|
||||
);
|
||||
await AndroidAlarmManager.initialize();
|
||||
runApp(MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AppsProvider(
|
||||
shouldLoadApps: true,
|
||||
shouldCheckUpdatesAfterLoad: false,
|
||||
shouldDeleteAPKs: true)),
|
||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
Provider(create: (context) => NotificationsProvider())
|
||||
],
|
||||
@ -138,7 +134,7 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
Permission.notification.request();
|
||||
appsProvider.saveApps([
|
||||
App(
|
||||
'imranr98_obtainium_${GitHub().host}',
|
||||
'dev.imranr.obtainium',
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
@ -147,24 +143,22 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
[],
|
||||
0,
|
||||
['true'],
|
||||
null)
|
||||
null,
|
||||
false)
|
||||
]);
|
||||
}
|
||||
// Register the background update task according to the user's setting
|
||||
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||
existingUpdateInterval = settingsProvider.updateInterval;
|
||||
if (existingUpdateInterval == 0) {
|
||||
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
|
||||
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||
} else {
|
||||
Workmanager().registerPeriodicTask(
|
||||
bgUpdateCheckTaskName, bgUpdateCheckTaskName,
|
||||
frequency: Duration(minutes: existingUpdateInterval),
|
||||
initialDelay: Duration(minutes: existingUpdateInterval),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingWorkPolicy.replace,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
backoffPolicyDelay:
|
||||
const Duration(minutes: minUpdateIntervalMinutes));
|
||||
AndroidAlarmManager.periodic(
|
||||
Duration(minutes: existingUpdateInterval),
|
||||
bgUpdateCheckAlarmId,
|
||||
bgUpdateCheck,
|
||||
rescheduleOnReboot: true,
|
||||
wakeup: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class GitHubStars implements MassAppSource {
|
||||
class GitHubStars implements MassAppUrlSource {
|
||||
@override
|
||||
late String name = 'GitHub Starred Repos';
|
||||
|
||||
@ -28,14 +28,14 @@ class GitHubStars implements MassAppSource {
|
||||
.round());
|
||||
}
|
||||
|
||||
throw 'Unable to find user\'s starred repos';
|
||||
throw ObtainiumError('Unable to find user\'s starred repos');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getUrls(List<String> args) async {
|
||||
if (args.length != requiredArgs.length) {
|
||||
throw 'Wrong number of arguments provided';
|
||||
throw ObtainiumError('Wrong number of arguments provided');
|
||||
}
|
||||
List<String> urls = [];
|
||||
var page = 1;
|
||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -22,7 +23,6 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
String userInput = '';
|
||||
AppSource? pickedSource;
|
||||
List<String> additionalData = [];
|
||||
String customName = '';
|
||||
bool validAdditionalData = true;
|
||||
|
||||
@override
|
||||
@ -57,7 +57,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
} catch (e) {
|
||||
return e is String
|
||||
? e
|
||||
: 'Error';
|
||||
: e is ObtainiumError
|
||||
? e.toString()
|
||||
: 'Error';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -77,12 +79,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
: [];
|
||||
validAdditionalData = source != null
|
||||
? sourceProvider
|
||||
.doesSourceHaveRequiredAdditionalData(
|
||||
.ifSourceAppsRequireAdditionalData(
|
||||
source)
|
||||
: true;
|
||||
if (source == null) {
|
||||
customName = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -90,59 +89,73 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.additionalDataFormItems
|
||||
.isNotEmpty &&
|
||||
!validAdditionalData)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
sourceProvider
|
||||
.getApp(pickedSource!, userInput,
|
||||
additionalData,
|
||||
customName: customName)
|
||||
.then((app) {
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
if (appsProvider.apps
|
||||
.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
appsProvider
|
||||
.saveApps([app]).then((_) {
|
||||
gettingAppInfo
|
||||
? const CircularProgressIndicator()
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.additionalDataFormItems
|
||||
.isNotEmpty &&
|
||||
!validAdditionalData)
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
() async {
|
||||
HapticFeedback.selectionClick();
|
||||
App app =
|
||||
await sourceProvider.getApp(
|
||||
pickedSource!,
|
||||
userInput,
|
||||
additionalData);
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider
|
||||
.confirmApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw ObtainiumError('Cancelled');
|
||||
}
|
||||
app.preferredApkIndex =
|
||||
app.apkUrls.indexOf(apkUrl);
|
||||
var downloadedApk =
|
||||
await appsProvider
|
||||
.downloadApp(app);
|
||||
app.id = downloadedApk.appId;
|
||||
if (appsProvider.apps
|
||||
.containsKey(app.id)) {
|
||||
throw ObtainiumError(
|
||||
'App already added');
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
}()
|
||||
.then((app) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(
|
||||
appId: app.id)));
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: const Text('Add'))
|
||||
},
|
||||
child: const Text('Add'))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null)
|
||||
if (pickedSource != null &&
|
||||
pickedSource!.additionalDataDefaults.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@ -174,21 +187,6 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
if (pickedSource != null)
|
||||
GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Custom App Name',
|
||||
required: false)
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
customName = values[0];
|
||||
});
|
||||
},
|
||||
defaultValues: [customName])
|
||||
],
|
||||
)
|
||||
else
|
||||
@ -197,9 +195,6 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// const SizedBox(
|
||||
// height: 48,
|
||||
// ),
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -26,10 +26,8 @@ class _AppPageState extends State<AppPage> {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
getUpdate(String id) {
|
||||
appsProvider.getUpdate(id).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
appsProvider.checkUpdate(id).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
|
||||
@ -46,6 +44,7 @@ class _AppPageState extends State<AppPage> {
|
||||
body: RefreshIndicator(
|
||||
child: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
@ -56,8 +55,22 @@ class _AppPageState extends State<AppPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
app?.installedInfo != null
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.memory(
|
||||
app!.installedInfo!.icon!,
|
||||
height: 150,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
])
|
||||
: Container(),
|
||||
const SizedBox(
|
||||
height: 25,
|
||||
),
|
||||
Text(
|
||||
app?.app.name ?? 'App',
|
||||
app?.installedInfo?.name ?? app?.app.name ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
@ -126,7 +139,8 @@ class _AppPageState extends State<AppPage> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != app?.app.latestVersion)
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
@ -135,8 +149,8 @@ class _AppPageState extends State<AppPage> {
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
|
||||
title: const Text(
|
||||
'App Already up to Date?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@ -161,54 +175,13 @@ class _AppPageState extends State<AppPage> {
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Installed'))
|
||||
'Yes, Mark as Updated'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Installed',
|
||||
icon: const Icon(Icons.done))
|
||||
else
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'App Not Installed?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp
|
||||
.installedVersion =
|
||||
null;
|
||||
appsProvider.saveApps(
|
||||
[updatedApp]);
|
||||
}
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Not Installed'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Not Installed',
|
||||
icon: const Icon(Icons.no_cell_outlined)),
|
||||
tooltip: 'Mark as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.additionalDataFormItems.isNotEmpty)
|
||||
IconButton(
|
||||
@ -220,30 +193,15 @@ class _AppPageState extends State<AppPage> {
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Additional Options',
|
||||
items: [
|
||||
...source
|
||||
.additionalDataFormItems,
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'App Name',
|
||||
required: true)
|
||||
]
|
||||
],
|
||||
items: source
|
||||
.additionalDataFormItems,
|
||||
defaultValues: app != null
|
||||
? [
|
||||
...app
|
||||
.app.additionalData,
|
||||
app.app.name
|
||||
]
|
||||
: [
|
||||
...source
|
||||
.additionalDataDefaults
|
||||
]);
|
||||
? app.app.additionalData
|
||||
: source
|
||||
.additionalDataDefaults);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
var name = values.removeLast();
|
||||
changedApp.name = name;
|
||||
changedApp.additionalData = values;
|
||||
appsProvider.saveApps(
|
||||
[changedApp]).then((value) {
|
||||
@ -258,19 +216,20 @@ class _AppPageState extends State<AppPage> {
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
appsProvider
|
||||
.checkAppObjectForUpdate(
|
||||
app!.app)) &&
|
||||
app?.app.installedVersion !=
|
||||
app?.app.latestVersion) &&
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApp(
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
@ -288,7 +247,7 @@ class _AppPageState extends State<AppPage> {
|
||||
return AlertDialog(
|
||||
title: const Text('Remove App?'),
|
||||
content: Text(
|
||||
'This will remove \'${app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
||||
'This will remove \'${app?.installedInfo?.name ?? app?.app.name}\' from Obtainium.${app?.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -22,23 +23,24 @@ class AppsPageState extends State<AppsPage> {
|
||||
AppsFilter? filter;
|
||||
var updatesOnlyFilter =
|
||||
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||
Set<String> selectedIds = {};
|
||||
Set<App> selectedApps = {};
|
||||
DateTime? refreshingSince;
|
||||
|
||||
clearSelected() {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
if (selectedApps.isNotEmpty) {
|
||||
setState(() {
|
||||
selectedIds.clear();
|
||||
selectedApps.clear();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
selectThese(List<String> appIds) {
|
||||
if (selectedIds.isEmpty) {
|
||||
selectThese(List<App> apps) {
|
||||
if (selectedApps.isEmpty) {
|
||||
setState(() {
|
||||
for (var a in appIds) {
|
||||
selectedIds.add(a);
|
||||
for (var a in apps) {
|
||||
selectedApps.add(a);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -52,16 +54,16 @@ class AppsPageState extends State<AppsPage> {
|
||||
var currentFilterIsUpdatesOnly =
|
||||
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||
|
||||
selectedIds = selectedIds
|
||||
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
|
||||
selectedApps = selectedApps
|
||||
.where((element) => sortedApps.map((e) => e.app).contains(element))
|
||||
.toSet();
|
||||
|
||||
toggleAppSelected(String appId) {
|
||||
toggleAppSelected(App app) {
|
||||
setState(() {
|
||||
if (selectedIds.contains(appId)) {
|
||||
selectedIds.remove(appId);
|
||||
if (selectedApps.contains(app)) {
|
||||
selectedApps.remove(app);
|
||||
} else {
|
||||
selectedIds.add(appId);
|
||||
selectedApps.add(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -89,7 +91,8 @@ class AppsPageState extends State<AppsPage> {
|
||||
.toList();
|
||||
|
||||
for (var t in nameTokens) {
|
||||
if (!app.app.name.toLowerCase().contains(t.toLowerCase())) {
|
||||
var name = app.installedInfo?.name ?? app.app.name;
|
||||
if (!name.toLowerCase().contains(t.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -103,13 +106,13 @@ class AppsPageState extends State<AppsPage> {
|
||||
}
|
||||
|
||||
sortedApps.sort((a, b) {
|
||||
var nameA = a.installedInfo?.name ?? a.app.name;
|
||||
var nameB = b.installedInfo?.name ?? b.app.name;
|
||||
int result = 0;
|
||||
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
||||
result =
|
||||
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
|
||||
result = (a.app.author + nameA).compareTo(b.app.author + nameB);
|
||||
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
||||
result =
|
||||
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
|
||||
result = (nameA + a.app.author).compareTo(nameB + b.app.author);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@ -118,28 +121,57 @@ class AppsPageState extends State<AppsPage> {
|
||||
sortedApps = sortedApps.reversed.toList();
|
||||
}
|
||||
|
||||
var existingUpdateIdsAllOrSelected = appsProvider
|
||||
.getExistingUpdates(installedOnly: true)
|
||||
.where((element) => selectedIds.isEmpty
|
||||
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||
|
||||
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
var newInstallIdsAllOrSelected = appsProvider
|
||||
.getExistingUpdates(nonInstalledOnly: true)
|
||||
.where((element) => selectedIds.isEmpty
|
||||
.findExistingUpdates(nonInstalledOnly: true)
|
||||
.where((element) => selectedApps.isEmpty
|
||||
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||
: selectedIds.contains(element))
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.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];
|
||||
}
|
||||
|
||||
var tempPinned = [];
|
||||
var tempNotPinned = [];
|
||||
for (var a in sortedApps) {
|
||||
if (a.app.pinned) {
|
||||
tempPinned.add(a);
|
||||
} else {
|
||||
tempNotPinned.add(a);
|
||||
}
|
||||
}
|
||||
sortedApps = [...tempPinned, ...tempNotPinned];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
refreshingSince = DateTime.now();
|
||||
});
|
||||
return appsProvider.checkUpdates().catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
refreshingSince = null;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: CustomScrollView(slivers: <Widget>[
|
||||
@ -156,18 +188,51 @@ class AppsPageState extends State<AppsPage> {
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
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(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return ListTile(
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||
selected: selectedIds.contains(sortedApps[index].app.id),
|
||||
tileColor: sortedApps[index].app.pinned
|
||||
? Colors.grey.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1),
|
||||
selected: selectedApps.contains(sortedApps[index].app),
|
||||
onLongPress: () {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
},
|
||||
title: Text(sortedApps[index].app.name),
|
||||
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||
leading: sortedApps[index].installedInfo != null
|
||||
? Image.memory(
|
||||
sortedApps[index].installedInfo!.icon!,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
: null,
|
||||
title: Text(
|
||||
sortedApps[index].installedInfo?.name ??
|
||||
sortedApps[index].app.name,
|
||||
style: TextStyle(
|
||||
fontWeight: sortedApps[index].app.pinned
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal),
|
||||
),
|
||||
subtitle: Text('By ${sortedApps[index].app.author}',
|
||||
style: TextStyle(
|
||||
fontWeight: sortedApps[index].app.pinned
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal)),
|
||||
trailing: sortedApps[index].downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||
@ -178,7 +243,9 @@ class AppsPageState extends State<AppsPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const Text('Update Available'),
|
||||
Text(appsProvider.areDownloadsRunning()
|
||||
? 'Please Wait...'
|
||||
: 'Update Available'),
|
||||
SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(
|
||||
@ -205,11 +272,18 @@ class AppsPageState extends State<AppsPage> {
|
||||
)),
|
||||
],
|
||||
)
|
||||
: Text(sortedApps[index].app.installedVersion ??
|
||||
'Not Installed')),
|
||||
: SingleChildScrollView(
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
sortedApps[index].app.installedVersion ??
|
||||
'Not Installed',
|
||||
overflow: TextOverflow.fade,
|
||||
textAlign: TextAlign.end,
|
||||
)))),
|
||||
onTap: () {
|
||||
if (selectedIds.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app.id);
|
||||
if (selectedApps.isNotEmpty) {
|
||||
toggleAppSelected(sortedApps[index].app);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
@ -227,25 +301,25 @@ class AppsPageState extends State<AppsPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
selectedIds.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app.id).toList())
|
||||
selectedApps.isEmpty
|
||||
? selectThese(sortedApps.map((e) => e.app).toList())
|
||||
: clearSelected();
|
||||
},
|
||||
icon: Icon(
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? Icons.select_all_outlined
|
||||
: Icons.deselect_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
tooltip: selectedIds.isEmpty
|
||||
tooltip: selectedApps.isEmpty
|
||||
? 'Select All'
|
||||
: 'Deselect ${selectedIds.length.toString()}'),
|
||||
: 'Deselect ${selectedApps.length.toString()}'),
|
||||
const VerticalDivider(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -259,11 +333,12 @@ class AppsPageState extends State<AppsPage> {
|
||||
defaultValues: const [],
|
||||
initValid: true,
|
||||
message:
|
||||
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
|
||||
'${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.',
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
appsProvider.removeApps(selectedIds.toList());
|
||||
appsProvider.removeApps(
|
||||
selectedApps.map((e) => e.id).toList());
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -299,19 +374,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
message:
|
||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||
items: formInputs,
|
||||
defaultValues: const ['true', 'true'],
|
||||
defaultValues: [
|
||||
'true',
|
||||
existingUpdateIdsAllOrSelected.isEmpty
|
||||
? 'true'
|
||||
: ''
|
||||
],
|
||||
initValid: true,
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
bool shouldInstallUpdates =
|
||||
values.length < 2 || values[0] == 'true';
|
||||
bool shouldInstallNew =
|
||||
values.length < 2 || values[1] == 'true';
|
||||
bool shouldInstallUpdates = values[0] == 'true';
|
||||
bool shouldInstallNew = values[1] == 'true';
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
@ -324,18 +402,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
}
|
||||
appsProvider.downloadAndInstallLatestApp(
|
||||
toInstall, context);
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
toInstall, context)
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
|
||||
'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps',
|
||||
icon: const Icon(
|
||||
Icons.file_download_outlined,
|
||||
)),
|
||||
selectedIds.isEmpty
|
||||
selectedApps.isEmpty
|
||||
? const SizedBox()
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
@ -349,7 +431,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed:
|
||||
@ -364,7 +446,10 @@ class AppsPageState extends State<AppsPage> {
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Not Installed?'),
|
||||
'Mark ${selectedApps.length} Selected Apps as Updated?'),
|
||||
content:
|
||||
const Text(
|
||||
'Only applies to installed but out of date Apps.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
@ -380,11 +465,11 @@ class AppsPageState extends State<AppsPage> {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedIds.map((e) {
|
||||
var a =
|
||||
appsProvider.apps[e]!.app;
|
||||
a.installedVersion =
|
||||
null;
|
||||
.saveApps(selectedApps.map((a) {
|
||||
if (a.installedVersion !=
|
||||
null) {
|
||||
a.installedVersion = a.latestVersion;
|
||||
}
|
||||
return a;
|
||||
}).toList());
|
||||
|
||||
@ -395,73 +480,50 @@ class AppsPageState extends State<AppsPage> {
|
||||
'Yes'))
|
||||
],
|
||||
);
|
||||
});
|
||||
}).whenComplete(() {
|
||||
Navigator.of(
|
||||
context)
|
||||
.pop();
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as Not Installed',
|
||||
icon: const Icon(
|
||||
Icons.no_cell_outlined)),
|
||||
IconButton(
|
||||
onPressed:
|
||||
appsProvider
|
||||
.areDownloadsRunning()
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Mark ${selectedIds.length} Selected Apps as Installed/Updated?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'No')),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() {
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.saveApps(selectedIds.map((e) {
|
||||
var a =
|
||||
appsProvider.apps[e]!.app;
|
||||
a.installedVersion =
|
||||
a.latestVersion;
|
||||
return a;
|
||||
}).toList());
|
||||
|
||||
Navigator.of(context)
|
||||
.pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip:
|
||||
'Mark Selected Apps as Installed/Updated',
|
||||
'Mark Selected Apps as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
var pinStatus = selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty;
|
||||
appsProvider.saveApps(
|
||||
selectedApps.map((e) {
|
||||
e.pinned = pinStatus;
|
||||
return e;
|
||||
}).toList());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip:
|
||||
'${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top',
|
||||
icon: Icon(selectedApps
|
||||
.where((element) =>
|
||||
element.pinned)
|
||||
.isEmpty
|
||||
? Icons.bookmark_outline_rounded
|
||||
: Icons
|
||||
.bookmark_remove_outlined),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
String urls = '';
|
||||
for (var id in selectedIds) {
|
||||
urls +=
|
||||
'${appsProvider.apps[id]!.app.url}\n';
|
||||
for (var a in selectedApps) {
|
||||
urls += '${a.url}\n';
|
||||
}
|
||||
urls = urls.substring(
|
||||
0, urls.length - 1);
|
||||
Share.share(urls,
|
||||
subject:
|
||||
'${selectedIds.length} Selected App URLs from Obtainium');
|
||||
'${selectedApps.length} Selected App URLs from Obtainium');
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
tooltip: 'Share Selected App URLs',
|
||||
icon: const Icon(Icons.share),
|
||||
|
@ -92,7 +92,6 @@ class _HomePageState extends State<HomePage> {
|
||||
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
|
||||
.currentState
|
||||
?.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/generated_form.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/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -61,7 +62,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Import/Export'),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
@ -81,12 +81,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
appsProvider
|
||||
.exportApps()
|
||||
.then((String path) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Exported to $path')),
|
||||
);
|
||||
showError(
|
||||
'Exported to $path', context);
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Export'))),
|
||||
@ -113,27 +109,21 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
try {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw 'Invalid input';
|
||||
throw ObtainiumError(
|
||||
'Invalid input');
|
||||
}
|
||||
appsProvider
|
||||
.importApps(data)
|
||||
.then((value) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||
);
|
||||
showError(
|
||||
'$value App${value == 1 ? '' : 's'} Imported',
|
||||
context);
|
||||
});
|
||||
} else {
|
||||
// User canceled the picker
|
||||
}
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString())),
|
||||
);
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
@ -208,12 +198,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
});
|
||||
addApps(urls).then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported ${urls.length} Apps')),
|
||||
);
|
||||
showError(
|
||||
'Imported ${urls.length} Apps',
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
@ -224,10 +211,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
@ -239,7 +223,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
child: const Text(
|
||||
'Import from URL List',
|
||||
)),
|
||||
...sourceProvider.massSources
|
||||
...sourceProvider.sources
|
||||
.where((element) => element.canSearch)
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
@ -249,99 +234,186 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Import ${source.name}',
|
||||
items: source
|
||||
.requiredArgs
|
||||
.map((e) => [
|
||||
GeneratedFormItem(
|
||||
label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
() async {
|
||||
var values = await showDialog<
|
||||
List<String>>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Search ${source.runtimeType}',
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'${source.runtimeType} Search Query')
|
||||
]
|
||||
],
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null &&
|
||||
values[0].isNotEmpty) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
source
|
||||
.getUrls(values)
|
||||
.then((urls) {
|
||||
showDialog<List<String>?>(
|
||||
var urls = await source
|
||||
.search(values[0]);
|
||||
if (urls.isNotEmpty) {
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<
|
||||
String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urls: urls);
|
||||
})
|
||||
.then((selectedUrls) {
|
||||
if (selectedUrls !=
|
||||
null) {
|
||||
addApps(selectedUrls)
|
||||
.then((errors) {
|
||||
if (errors
|
||||
.isEmpty) {
|
||||
ScaffoldMessenger
|
||||
.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported ${selectedUrls.length} Apps')),
|
||||
);
|
||||
} else {
|
||||
showDialog(
|
||||
context:
|
||||
context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
});
|
||||
urls: urls,
|
||||
defaultSelected:
|
||||
false,
|
||||
);
|
||||
});
|
||||
if (selectedUrls !=
|
||||
null &&
|
||||
selectedUrls
|
||||
.isNotEmpty) {
|
||||
var errors =
|
||||
await addApps(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
'Imported ${selectedUrls.length} Apps',
|
||||
context);
|
||||
} else {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
setState(() {
|
||||
importInProgress =
|
||||
false;
|
||||
});
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e.toString())),
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(
|
||||
'No results found');
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
'Search ${source.runtimeType}'))
|
||||
]))
|
||||
.toList(),
|
||||
...sourceProvider.massUrlSources
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
() async {
|
||||
var values = await showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Import ${source.name}',
|
||||
items:
|
||||
source
|
||||
.requiredArgs
|
||||
.map(
|
||||
(e) => [
|
||||
GeneratedFormItem(label: e)
|
||||
])
|
||||
.toList(),
|
||||
defaultValues: const [],
|
||||
);
|
||||
});
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urls = await source
|
||||
.getUrls(values);
|
||||
var selectedUrls =
|
||||
await showDialog<
|
||||
List<String>?>(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return UrlSelectionModal(
|
||||
urls: urls);
|
||||
});
|
||||
if (selectedUrls != null) {
|
||||
var errors =
|
||||
await addApps(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
'Imported ${selectedUrls.length} Apps',
|
||||
context);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext
|
||||
ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
selectedUrls
|
||||
.length,
|
||||
errors:
|
||||
errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
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,
|
||||
)
|
||||
],
|
||||
)))
|
||||
]));
|
||||
@ -404,9 +476,11 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class UrlSelectionModal extends StatefulWidget {
|
||||
UrlSelectionModal({super.key, required this.urls});
|
||||
UrlSelectionModal(
|
||||
{super.key, required this.urls, this.defaultSelected = true});
|
||||
|
||||
List<String> urls;
|
||||
bool defaultSelected;
|
||||
|
||||
@override
|
||||
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||
@ -418,7 +492,7 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var url in widget.urls) {
|
||||
urlSelections.putIfAbsent(url, () => true);
|
||||
urlSelections.putIfAbsent(url, () => widget.defaultSelected);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,143 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (settingsProvider.prefs == null) {
|
||||
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(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
@ -38,112 +175,22 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}),
|
||||
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,
|
||||
),
|
||||
themeDropdown,
|
||||
height16,
|
||||
colourDropdown,
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
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;
|
||||
}
|
||||
})),
|
||||
Expanded(child: sortDropdown),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
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;
|
||||
}
|
||||
})),
|
||||
Expanded(child: orderDropdown),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
height16,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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(
|
||||
height: 16,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
height16,
|
||||
Text(
|
||||
'Updates',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Text(
|
||||
'Longer intervals recommended for large App collections',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium!
|
||||
.merge(const TextStyle(
|
||||
fontStyle: FontStyle.italic)),
|
||||
),
|
||||
intervalDropdown,
|
||||
const Divider(
|
||||
height: 48,
|
||||
),
|
||||
@ -214,42 +232,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
...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();
|
||||
}
|
||||
}),
|
||||
...sourceSpecificFields,
|
||||
],
|
||||
))),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
height16,
|
||||
TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||
@ -267,9 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
height16,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
@ -8,9 +8,14 @@ import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||
import 'package:installed_apps/app_info.dart';
|
||||
import 'package:installed_apps/installed_apps.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:package_archive_info/package_archive_info.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||
@ -20,14 +25,15 @@ import 'package:http/http.dart';
|
||||
class AppInMemory {
|
||||
late App app;
|
||||
double? downloadProgress;
|
||||
AppInfo? installedInfo;
|
||||
|
||||
AppInMemory(this.app, this.downloadProgress);
|
||||
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
||||
}
|
||||
|
||||
class ApkFile {
|
||||
class DownloadedApk {
|
||||
String appId;
|
||||
File file;
|
||||
ApkFile(this.appId, this.file);
|
||||
DownloadedApk(this.appId, this.file);
|
||||
}
|
||||
|
||||
class AppsProvider with ChangeNotifier {
|
||||
@ -35,66 +41,117 @@ class AppsProvider with ChangeNotifier {
|
||||
Map<String, AppInMemory> apps = {};
|
||||
bool loadingApps = false;
|
||||
bool gettingUpdates = false;
|
||||
bool forBGTask = false;
|
||||
|
||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||
bool isForeground = true;
|
||||
late Stream<FGBGType> foregroundStream;
|
||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
||||
late Stream<FGBGType>? foregroundStream;
|
||||
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||
|
||||
AppsProvider(
|
||||
{bool shouldLoadApps = false,
|
||||
bool shouldCheckUpdatesAfterLoad = false,
|
||||
bool shouldDeleteAPKs = false}) {
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||
foregroundSubscription = foregroundStream.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
});
|
||||
if (shouldDeleteAPKs) {
|
||||
deleteSavedAPKs();
|
||||
}
|
||||
if (shouldLoadApps) {
|
||||
loadApps().then((_) {
|
||||
if (shouldCheckUpdatesAfterLoad) {
|
||||
checkUpdates();
|
||||
}
|
||||
AppsProvider({this.forBGTask = false}) {
|
||||
// Many setup tasks should only be done in the foreground isolate
|
||||
if (!forBGTask) {
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
});
|
||||
() async {
|
||||
// Load Apps into memory (in background, this is done later instead of in the constructor)
|
||||
await loadApps();
|
||||
// Delete existing APKs
|
||||
(await getExternalStorageDirectory())
|
||||
?.listSync()
|
||||
.where((element) => element.path.endsWith('.apk'))
|
||||
.forEach((apk) {
|
||||
apk.delete();
|
||||
});
|
||||
}();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
||||
apkUrl = await SourceProvider()
|
||||
.getSource(apps[appId]!.app.url)
|
||||
.apkUrlPrefetchModifier(apkUrl);
|
||||
downloadFile(String url, String fileName, Function? onProgress) async {
|
||||
var destDir = (await getExternalStorageDirectory())!.path;
|
||||
StreamedResponse response =
|
||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
||||
File downloadFile =
|
||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
||||
if (downloadFile.existsSync()) {
|
||||
downloadFile.deleteSync();
|
||||
await Client().send(Request('GET', Uri.parse(url)));
|
||||
File downloadedFile = File('$destDir/$fileName');
|
||||
|
||||
if (downloadedFile.existsSync()) {
|
||||
downloadedFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
var sink = downloadFile.openWrite();
|
||||
double? progress;
|
||||
var sink = downloadedFile.openWrite();
|
||||
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
apps[appId]!.downloadProgress =
|
||||
(length != null ? received / length * 100 : 30);
|
||||
notifyListeners();
|
||||
progress = (length != null ? received / length * 100 : 30);
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
|
||||
await sink.close();
|
||||
apps[appId]!.downloadProgress = null;
|
||||
notifyListeners();
|
||||
progress = null;
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
downloadFile.deleteSync();
|
||||
downloadedFile.deleteSync();
|
||||
throw response.reasonPhrase ?? 'Unknown Error';
|
||||
}
|
||||
return ApkFile(appId, downloadFile);
|
||||
return downloadedFile;
|
||||
}
|
||||
|
||||
Future<DownloadedApk> downloadApp(App app) async {
|
||||
var fileName =
|
||||
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk';
|
||||
String downloadUrl = await SourceProvider()
|
||||
.getSource(app.url)
|
||||
.apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]);
|
||||
int? prevProg;
|
||||
File downloadedFile =
|
||||
await downloadFile(downloadUrl, fileName, (double? progress) {
|
||||
int? prog = progress?.ceil();
|
||||
if (apps[app.id] != null) {
|
||||
apps[app.id]!.downloadProgress = progress;
|
||||
notifyListeners();
|
||||
} else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT);
|
||||
}
|
||||
prevProg = prog;
|
||||
});
|
||||
// Delete older versions of the APK if any
|
||||
for (var file in downloadedFile.parent.listSync()) {
|
||||
var fn = file.path.split('/').last;
|
||||
if (fn.startsWith('${app.id}-') &&
|
||||
fn.endsWith('.apk') &&
|
||||
fn != fileName) {
|
||||
file.delete();
|
||||
}
|
||||
}
|
||||
// If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
|
||||
// The former case should be handled (give the App its real ID), the latter is a security issue
|
||||
var newInfo = await PackageArchiveInfo.fromPath(downloadedFile.path);
|
||||
if (app.id != newInfo.packageName) {
|
||||
if (apps[app.id] != null && !SourceProvider().isTempId(app.id)) {
|
||||
throw IDChangedError();
|
||||
}
|
||||
var originalAppId = app.id;
|
||||
app.id = newInfo.packageName;
|
||||
downloadedFile = downloadedFile.renameSync(
|
||||
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||
if (apps[originalAppId] != null) {
|
||||
await removeApps([originalAppId]);
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
return DownloadedApk(app.id, downloadedFile);
|
||||
}
|
||||
|
||||
bool areDownloadsRunning() => apps.values
|
||||
@ -102,24 +159,26 @@ class AppsProvider with ChangeNotifier {
|
||||
.isNotEmpty;
|
||||
|
||||
Future<bool> canInstallSilently(App app) async {
|
||||
// TODO: This is unreliable - try to get from OS in the future
|
||||
var osInfo = await DeviceInfoPlugin().androidInfo;
|
||||
return app.installedVersion != null &&
|
||||
osInfo.version.sdkInt! >= 30 &&
|
||||
osInfo.version.release!.compareTo('12') >= 0;
|
||||
return false;
|
||||
// TODO: Uncomment the below once silentupdates are ever figured out
|
||||
// // TODO: This is unreliable - try to get from OS in the future
|
||||
// if (app.apkUrls.length > 1) {
|
||||
// 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,
|
||||
{bool waitForFG = false}) async {
|
||||
Future<void> waitForUserToReturnToForeground(BuildContext context) async {
|
||||
NotificationsProvider notificationsProvider =
|
||||
context.read<NotificationsProvider>();
|
||||
if (!isForeground) {
|
||||
await notificationsProvider.notify(completeInstallationNotification,
|
||||
cancelExisting: true);
|
||||
if (waitForFG) {
|
||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||
}
|
||||
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
|
||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,11 +186,61 @@ class AppsProvider with ChangeNotifier {
|
||||
// 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
|
||||
// But even then, we don't know if it actually succeeded
|
||||
Future<void> installApk(ApkFile file) async {
|
||||
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||
Future<void> installApk(DownloadedApk file) async {
|
||||
var newInfo = await PackageArchiveInfo.fromPath(file.file.path);
|
||||
AppInfo? appInfo;
|
||||
try {
|
||||
appInfo = await InstalledApps.getAppInfo(apps[file.appId]!.app.id);
|
||||
} catch (e) {
|
||||
// OK
|
||||
}
|
||||
if (appInfo != null &&
|
||||
int.parse(newInfo.buildNumber) < appInfo.versionCode!) {
|
||||
throw DowngradeError();
|
||||
}
|
||||
if (appInfo == null ||
|
||||
int.parse(newInfo.buildNumber) > appInfo.versionCode!) {
|
||||
await InstallPlugin.installApk(file.file.path, 'dev.imranr.obtainium');
|
||||
}
|
||||
apps[file.appId]!.app.installedVersion =
|
||||
apps[file.appId]!.app.latestVersion;
|
||||
await saveApps([apps[file.appId]!.app]);
|
||||
// Don't correct install status as installation may not be done yet
|
||||
await saveApps([apps[file.appId]!.app],
|
||||
attemptToCorrectInstallStatus: false);
|
||||
}
|
||||
|
||||
Future<String?> confirmApkUrl(App app, BuildContext? context) async {
|
||||
// If the App has more than one APK, the user should pick one (if context provided)
|
||||
String? apkUrl = app.apkUrls[app.preferredApkIndex];
|
||||
// get device supported architecture
|
||||
List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
|
||||
|
||||
if (app.apkUrls.length > 1 && context != null) {
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
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 (apkUrl != null &&
|
||||
Uri.parse(apkUrl).origin != Uri.parse(app.url).origin &&
|
||||
context != null) {
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKOriginWarningDialog(
|
||||
sourceUrl: app.url, apkUrl: apkUrl!);
|
||||
}) !=
|
||||
true) {
|
||||
apkUrl = null;
|
||||
}
|
||||
}
|
||||
return apkUrl;
|
||||
}
|
||||
|
||||
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
|
||||
@ -139,58 +248,46 @@ class AppsProvider with ChangeNotifier {
|
||||
// If no BuildContext is provided, apps that require user interaction are ignored
|
||||
// If user input is needed and the App is in the background, a notification is sent to get the user's attention
|
||||
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
|
||||
Future<List<String>> downloadAndInstallLatestApp(
|
||||
Future<List<String>> downloadAndInstallLatestApps(
|
||||
List<String> appIds, BuildContext? context) async {
|
||||
Map<String, 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) {
|
||||
if (apps[id] == null) {
|
||||
throw 'App not found';
|
||||
}
|
||||
|
||||
// If the App has more than one APK, the user should pick one (if context provided)
|
||||
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
||||
if (apps[id]!.app.apkUrls.length > 1 && context != null) {
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
||||
});
|
||||
}
|
||||
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||
if (apkUrl != null &&
|
||||
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin &&
|
||||
context != null) {
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKOriginWarningDialog(
|
||||
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
|
||||
}) !=
|
||||
true) {
|
||||
apkUrl = null;
|
||||
}
|
||||
throw ObtainiumError('App not found');
|
||||
}
|
||||
String? apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||
if (apkUrl != null) {
|
||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||
apps[id]!.app.preferredApkIndex = urlInd;
|
||||
await saveApps([apps[id]!.app]);
|
||||
}
|
||||
if (context != null ||
|
||||
(await canInstallSilently(apps[id]!.app) &&
|
||||
apps[id]!.app.apkUrls.length == 1)) {
|
||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
||||
if (context != null || await canInstallSilently(apps[id]!.app)) {
|
||||
appsToInstall.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
||||
.map((entry) => downloadApp(entry.value, entry.key)));
|
||||
|
||||
List<ApkFile> silentUpdates = [];
|
||||
List<ApkFile> regularInstalls = [];
|
||||
// Download APKs for all Apps to be installed
|
||||
MultiAppMultiError errors = MultiAppMultiError();
|
||||
List<DownloadedApk?> downloadedFiles =
|
||||
await Future.wait(appsToInstall.map((id) async {
|
||||
try {
|
||||
return await downloadApp(apps[id]!.app);
|
||||
} catch (e) {
|
||||
errors.add(id, e.toString());
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
downloadedFiles =
|
||||
downloadedFiles.where((element) => element != null).toList();
|
||||
// Separate the Apps to install into silent and regular lists
|
||||
List<DownloadedApk> silentUpdates = [];
|
||||
List<DownloadedApk> regularInstalls = [];
|
||||
for (var f in downloadedFiles) {
|
||||
bool willBeSilent = await canInstallSilently(apps[f.appId]!.app);
|
||||
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||
if (willBeSilent) {
|
||||
silentUpdates.add(f);
|
||||
} else {
|
||||
@ -198,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
|
||||
List<ApkFile> moveObtainiumToEnd(List<ApkFile> items) {
|
||||
List<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
||||
ApkFile? temp;
|
||||
DownloadedApk? temp;
|
||||
items.removeWhere((element) {
|
||||
bool res = element.appId == obtainiumId;
|
||||
if (res) {
|
||||
@ -210,33 +310,39 @@ class AppsProvider with ChangeNotifier {
|
||||
return res;
|
||||
});
|
||||
if (temp != null) {
|
||||
items.add(temp!);
|
||||
items = [temp!, ...items];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// TODO: Remove below line if silentupdates are ever figured out
|
||||
regularInstalls.addAll(silentUpdates);
|
||||
silentUpdates = moveObtainiumToStart(silentUpdates);
|
||||
regularInstalls = moveObtainiumToStart(regularInstalls);
|
||||
|
||||
silentUpdates = moveObtainiumToEnd(silentUpdates);
|
||||
regularInstalls = moveObtainiumToEnd(regularInstalls);
|
||||
|
||||
// TODO: Uncomment below if silentupdates are ever figured out
|
||||
// // Install silent updates (uncomment when it works - TODO)
|
||||
// for (var u in silentUpdates) {
|
||||
// await installApk(u, silent: true); // Would need to add silent option
|
||||
// }
|
||||
|
||||
if (context != null) {
|
||||
if (regularInstalls.isNotEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await askUserToReturnToForeground(context, waitForFG: true);
|
||||
}
|
||||
// Do regular installs
|
||||
if (regularInstalls.isNotEmpty && context != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await waitForUserToReturnToForeground(context);
|
||||
for (var i in regularInstalls) {
|
||||
await installApk(i);
|
||||
try {
|
||||
await installApk(i);
|
||||
} catch (e) {
|
||||
errors.add(i.appId, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedFiles.map((e) => e.appId).toList();
|
||||
if (errors.content.isNotEmpty) {
|
||||
throw errors;
|
||||
}
|
||||
|
||||
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||
|
||||
return downloadedFiles.map((e) => e!.appId).toList();
|
||||
}
|
||||
|
||||
Future<Directory> getAppsDir() async {
|
||||
@ -248,16 +354,62 @@ class AppsProvider with ChangeNotifier {
|
||||
return appsDir;
|
||||
}
|
||||
|
||||
Future<void> deleteSavedAPKs() async {
|
||||
(await getExternalStorageDirectory())
|
||||
?.listSync()
|
||||
.where((element) => element.path.endsWith('.apk'))
|
||||
.forEach((element) {
|
||||
element.deleteSync();
|
||||
});
|
||||
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||
if (packageName != null) {
|
||||
try {
|
||||
return await InstalledApps.getAppInfo(packageName);
|
||||
} catch (e) {
|
||||
// OK
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the App says it is installed but 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 latestVersion seems to match the version in installedInfo (not guaranteed)
|
||||
// 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;
|
||||
if (installedInfo == null && app.installedVersion != null) {
|
||||
app.installedVersion = null;
|
||||
modded = true;
|
||||
}
|
||||
if (installedInfo != null && app.installedVersion == null) {
|
||||
if (app.latestVersion.characters
|
||||
.where((p0) => [
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'.'
|
||||
].contains(p0))
|
||||
.join('') ==
|
||||
installedInfo.versionName) {
|
||||
app.installedVersion = app.latestVersion;
|
||||
} else {
|
||||
app.installedVersion = installedInfo.versionName;
|
||||
}
|
||||
modded = true;
|
||||
}
|
||||
return modded ? app : null;
|
||||
}
|
||||
|
||||
Future<void> loadApps() async {
|
||||
while (loadingApps) {
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
loadingApps = true;
|
||||
notifyListeners();
|
||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||
@ -265,22 +417,52 @@ class AppsProvider with ChangeNotifier {
|
||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||
.toList();
|
||||
apps.clear();
|
||||
var sp = SourceProvider();
|
||||
List<List<String>> errors = [];
|
||||
for (int i = 0; i < appFiles.length; i++) {
|
||||
App app =
|
||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
|
||||
var info = await getInstalledInfo(app.id);
|
||||
try {
|
||||
sp.getSource(app.url);
|
||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null, info));
|
||||
} catch (e) {
|
||||
errors.add([app.id, app.name, e.toString()]);
|
||||
}
|
||||
}
|
||||
if (errors.isNotEmpty) {
|
||||
removeApps(errors.map((e) => e[0]).toList());
|
||||
NotificationsProvider().notify(
|
||||
AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList()));
|
||||
}
|
||||
loadingApps = false;
|
||||
notifyListeners();
|
||||
List<App> modifiedApps = [];
|
||||
for (var app in apps.values) {
|
||||
var moddedApp =
|
||||
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
|
||||
if (moddedApp != null) {
|
||||
modifiedApps.add(moddedApp);
|
||||
}
|
||||
}
|
||||
if (modifiedApps.isNotEmpty) {
|
||||
await saveApps(modifiedApps);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveApps(List<App> apps) async {
|
||||
Future<void> saveApps(List<App> apps,
|
||||
{bool attemptToCorrectInstallStatus = true}) async {
|
||||
for (var app in apps) {
|
||||
AppInfo? info = await getInstalledInfo(app.id);
|
||||
app.name = info?.name ?? app.name;
|
||||
if (attemptToCorrectInstallStatus) {
|
||||
app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
|
||||
}
|
||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
||||
this.apps.update(
|
||||
app.id, (value) => AppInMemory(app, value.downloadProgress),
|
||||
ifAbsent: () => AppInMemory(app, null));
|
||||
app.id, (value) => AppInMemory(app, value.downloadProgress, info),
|
||||
ifAbsent: () => AppInMemory(app, null, info));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@ -300,21 +482,16 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
bool checkAppObjectForUpdate(App app) {
|
||||
if (!apps.containsKey(app.id)) {
|
||||
throw 'App not found';
|
||||
}
|
||||
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
||||
}
|
||||
|
||||
Future<App?> getUpdate(String appId) async {
|
||||
Future<App?> checkUpdate(String appId) async {
|
||||
App? currentApp = apps[appId]!.app;
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
App newApp = await sourceProvider.getApp(
|
||||
sourceProvider.getSource(currentApp.url),
|
||||
currentApp.url,
|
||||
currentApp.additionalData,
|
||||
customName: currentApp.name);
|
||||
name: currentApp.name,
|
||||
id: currentApp.id,
|
||||
pinned: currentApp.pinned);
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||
@ -323,35 +500,51 @@ class AppsProvider with ChangeNotifier {
|
||||
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
||||
}
|
||||
|
||||
Future<List<App>> checkUpdates({DateTime? ignoreAfter}) async {
|
||||
Future<List<App>> checkUpdates(
|
||||
{DateTime? ignoreAppsCheckedAfter,
|
||||
bool throwErrorsForRetry = false}) async {
|
||||
List<App> updates = [];
|
||||
MultiAppMultiError errors = MultiAppMultiError();
|
||||
if (!gettingUpdates) {
|
||||
gettingUpdates = true;
|
||||
|
||||
List<String> appIds = apps.keys.toList();
|
||||
if (ignoreAfter != null) {
|
||||
appIds = appIds
|
||||
.where((id) =>
|
||||
apps[id]!.app.lastUpdateCheck == null ||
|
||||
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
|
||||
try {
|
||||
List<String> appIds = apps.values
|
||||
.where((app) =>
|
||||
app.app.lastUpdateCheck == null ||
|
||||
ignoreAppsCheckedAfter == null ||
|
||||
app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter))
|
||||
.map((e) => e.app.id)
|
||||
.toList();
|
||||
}
|
||||
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
|
||||
DateTime.fromMicrosecondsSinceEpoch(0))
|
||||
.compareTo(apps[b]!.app.lastUpdateCheck ??
|
||||
DateTime.fromMicrosecondsSinceEpoch(0)));
|
||||
for (int i = 0; i < appIds.length; i++) {
|
||||
App? newApp = await getUpdate(appIds[i]);
|
||||
if (newApp != null) {
|
||||
updates.add(newApp);
|
||||
appIds.sort((a, b) => (apps[a]!.app.lastUpdateCheck ??
|
||||
DateTime.fromMicrosecondsSinceEpoch(0))
|
||||
.compareTo(apps[b]!.app.lastUpdateCheck ??
|
||||
DateTime.fromMicrosecondsSinceEpoch(0)));
|
||||
for (int i = 0; i < appIds.length; i++) {
|
||||
App? newApp;
|
||||
try {
|
||||
newApp = await checkUpdate(appIds[i]);
|
||||
} catch (e) {
|
||||
if ((e is RateLimitError || e is SocketException) &&
|
||||
throwErrorsForRetry) {
|
||||
rethrow;
|
||||
}
|
||||
errors.add(appIds[i], e.toString());
|
||||
}
|
||||
if (newApp != null) {
|
||||
updates.add(newApp);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
gettingUpdates = false;
|
||||
}
|
||||
gettingUpdates = false;
|
||||
}
|
||||
if (errors.content.isNotEmpty) {
|
||||
throw errors;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
List<String> getExistingUpdates(
|
||||
List<String> findExistingUpdates(
|
||||
{bool installedOnly = false, bool nonInstalledOnly = false}) {
|
||||
List<String> updateAppIds = [];
|
||||
List<String> appIds = apps.keys.toList();
|
||||
@ -385,13 +578,16 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
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>)
|
||||
.map((e) => App.fromJson(e))
|
||||
.toList();
|
||||
while (loadingApps) {
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
for (App a in importedApps) {
|
||||
a.installedVersion =
|
||||
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
|
||||
if (apps[a.id]?.app.installedVersion != null) {
|
||||
a.installedVersion = apps[a.id]?.app.installedVersion;
|
||||
}
|
||||
}
|
||||
await saveApps(importedApps);
|
||||
notifyListeners();
|
||||
@ -400,16 +596,17 @@ class AppsProvider with ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
foregroundSubscription.cancel();
|
||||
foregroundSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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 String? initVal;
|
||||
final List<String>? archs;
|
||||
|
||||
@override
|
||||
State<APKPicker> createState() => _APKPickerState();
|
||||
@ -427,18 +624,29 @@ class _APKPickerState extends State<APKPicker> {
|
||||
content: Column(children: [
|
||||
Text('${widget.app.name} has more than one package:'),
|
||||
const SizedBox(height: 16),
|
||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u)
|
||||
.pathSegments
|
||||
.where((element) => element.isNotEmpty)
|
||||
.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
setState(() {
|
||||
apkUrl = val;
|
||||
});
|
||||
}))
|
||||
...widget.app.apkUrls.map(
|
||||
(u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u)
|
||||
.pathSegments
|
||||
.where((element) => element.isNotEmpty)
|
||||
.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? 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: [
|
||||
TextButton(
|
||||
|
@ -27,9 +27,11 @@ class UpdateNotification extends ObtainiumNotification {
|
||||
'Updates Available',
|
||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
||||
Importance.max) {
|
||||
message = 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.';
|
||||
message = updates.isEmpty
|
||||
? "No new 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.';
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,6 +63,24 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
||||
Importance.high);
|
||||
}
|
||||
|
||||
class AppsRemovedNotification extends ObtainiumNotification {
|
||||
AppsRemovedNotification(List<List<String>> namedReasons)
|
||||
: super(
|
||||
6,
|
||||
'Apps Removed',
|
||||
'',
|
||||
'APPS_REMOVED',
|
||||
'Apps Removed',
|
||||
'Notifies the user that one or more Apps were removed due to errors while loading them',
|
||||
Importance.max) {
|
||||
message = '';
|
||||
for (var r in namedReasons) {
|
||||
message += '${r[0]} was removed due to this error: ${r[1]}. \n';
|
||||
}
|
||||
message = message.trim();
|
||||
}
|
||||
}
|
||||
|
||||
final completeInstallationNotification = ObtainiumNotification(
|
||||
1,
|
||||
'Complete App Installation',
|
||||
|
@ -55,7 +55,7 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
int get updateInterval {
|
||||
var min = prefs?.getInt('updateInterval') ?? 180;
|
||||
var min = prefs?.getInt('updateInterval') ?? 360;
|
||||
if (!updateIntervals.contains(min)) {
|
||||
var temp = updateIntervals[0];
|
||||
for (var i in updateIntervals) {
|
||||
@ -123,6 +123,15 @@ class SettingsProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get pinUpdates {
|
||||
return prefs?.getBool('pinUpdates') ?? true;
|
||||
}
|
||||
|
||||
set pinUpdates(bool show) {
|
||||
prefs?.setBool('pinUpdates', show);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? getSettingString(String settingId) {
|
||||
return prefs?.getString(settingId);
|
||||
}
|
||||
|
@ -4,7 +4,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/app_sources/gitlab.dart';
|
||||
@ -13,6 +12,7 @@ import 'package:obtainium/app_sources/mullvad.dart';
|
||||
import 'package:obtainium/app_sources/signal.dart';
|
||||
import 'package:obtainium/app_sources/sourceforge.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||
|
||||
class AppNames {
|
||||
@ -40,6 +40,7 @@ class App {
|
||||
late int preferredApkIndex;
|
||||
late List<String> additionalData;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -50,11 +51,12 @@ class App {
|
||||
this.apkUrls,
|
||||
this.preferredApkIndex,
|
||||
this.additionalData,
|
||||
this.lastUpdateCheck);
|
||||
this.lastUpdateCheck,
|
||||
this.pinned);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls';
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls PREFERREDAPK: $preferredApkIndex ADDITIONALDATA: ${additionalData.toString()} LASTCHECK: ${lastUpdateCheck.toString()} PINNED $pinned';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
@ -75,7 +77,8 @@ class App {
|
||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -87,16 +90,12 @@ class App {
|
||||
'apkUrls': jsonEncode(apkUrls),
|
||||
'preferredApkIndex': preferredApkIndex,
|
||||
'additionalData': jsonEncode(additionalData),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned
|
||||
};
|
||||
}
|
||||
|
||||
escapeRegEx(String s) {
|
||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return '\\${x[0]}';
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure the input is starts with HTTPS and has no WWW
|
||||
preStandardizeUrl(String url) {
|
||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||
url.toLowerCase().indexOf('https://') != 0) {
|
||||
@ -105,6 +104,11 @@ preStandardizeUrl(String url) {
|
||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
||||
url = 'https://${url.substring(12)}';
|
||||
}
|
||||
url = url
|
||||
.split('/')
|
||||
.where((e) => e.isNotEmpty)
|
||||
.join('/')
|
||||
.replaceFirst(':/', '://');
|
||||
return url;
|
||||
}
|
||||
|
||||
@ -128,20 +132,39 @@ List<String> getLinksFromParsedHTML(
|
||||
.map((e) => '$prependToLinks${e.attributes['href']!}')
|
||||
.toList();
|
||||
|
||||
abstract class AppSource {
|
||||
class AppSource {
|
||||
late String host;
|
||||
String standardizeURL(String url);
|
||||
String standardizeURL(String url) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData);
|
||||
AppNames getAppNames(String standardUrl);
|
||||
late List<List<GeneratedFormItem>> additionalDataFormItems;
|
||||
late List<String> additionalDataDefaults;
|
||||
late List<GeneratedFormItem> moreSourceSettingsFormItems;
|
||||
String? changeLogPageFromStandardUrl(String standardUrl);
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl);
|
||||
String standardUrl, List<String> additionalData) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
List<String> additionalDataDefaults = [];
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
bool canSearch = false;
|
||||
Future<List<String>> search(String query) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MassAppSource {
|
||||
abstract class MassAppUrlSource {
|
||||
late String name;
|
||||
late List<String> requiredArgs;
|
||||
Future<List<String>> getUrls(List<String> args);
|
||||
@ -156,12 +179,11 @@ class SourceProvider {
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
SourceForge(),
|
||||
APKMirror()
|
||||
SourceForge()
|
||||
];
|
||||
|
||||
// Add more mass source classes here so they are available via the service
|
||||
List<MassAppSource> massSources = [GitHubStars()];
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
|
||||
|
||||
AppSource getSource(String url) {
|
||||
url = preStandardizeUrl(url);
|
||||
@ -173,12 +195,12 @@ class SourceProvider {
|
||||
}
|
||||
}
|
||||
if (source == null) {
|
||||
throw 'URL does not match a known source';
|
||||
throw UnsupportedURLError();
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
bool doesSourceHaveRequiredAdditionalData(AppSource source) {
|
||||
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||
for (var row in source.additionalDataFormItems) {
|
||||
for (var element in row) {
|
||||
if (element.required) {
|
||||
@ -189,29 +211,45 @@ class SourceProvider {
|
||||
return false;
|
||||
}
|
||||
|
||||
String generateTempID(AppNames names, AppSource source) =>
|
||||
'${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,
|
||||
{String customName = ''}) async {
|
||||
{String name = '', String? id, bool pinned = false}) async {
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk =
|
||||
await source.getLatestAPKDetails(standardUrl, additionalData);
|
||||
return App(
|
||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
||||
id ?? generateTempID(names, source),
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
customName.trim().isNotEmpty
|
||||
? customName
|
||||
name.trim().isNotEmpty
|
||||
? name
|
||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version,
|
||||
apk.version.replaceAll('/', '-'),
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
additionalData,
|
||||
DateTime.now());
|
||||
DateTime.now(),
|
||||
pinned);
|
||||
}
|
||||
|
||||
/// Returns a length 2 list, where the first element is a list of Apps and
|
||||
/// the second is a Map<String, dynamic> of URLs and errors
|
||||
// Returns errors in [results, errors] instead of throwing them
|
||||
Future<List<dynamic>> getApps(List<String> urls,
|
||||
{List<String> ignoreUrls = const []}) async {
|
||||
List<App> apps = [];
|
||||
|
172
pubspec.lock
@ -1,6 +1,13 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -14,7 +21,7 @@ packages:
|
||||
name: archive
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
version: "3.3.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -71,6 +78,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -112,42 +126,14 @@ packages:
|
||||
name: device_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
device_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
device_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
version: "8.0.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
device_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
device_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
version: "7.0.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -182,7 +168,7 @@ packages:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.2.0+1"
|
||||
version: "5.2.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -194,7 +180,7 @@ packages:
|
||||
name: flutter_fgbg
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.2.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -215,14 +201,14 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "12.0.0"
|
||||
version: "12.0.3"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "2.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -253,14 +239,14 @@ packages:
|
||||
name: fluttertoast
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "8.0.9"
|
||||
version: "8.1.1"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.15.0"
|
||||
version: "0.15.1"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -274,14 +260,14 @@ packages:
|
||||
name: http_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "4.0.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.2.2"
|
||||
install_plugin_v2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -289,6 +275,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
installed_apps:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: installed_apps
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -309,7 +302,7 @@ packages:
|
||||
name: lints
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.0.1"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -323,7 +316,7 @@ packages:
|
||||
name: material_color_utilities
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.1.5"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -345,6 +338,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
package_archive_info:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_archive_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
package_info:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -365,7 +372,7 @@ packages:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.20"
|
||||
version: "2.0.21"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -407,21 +414,21 @@ packages:
|
||||
name: permission_handler
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "10.1.0"
|
||||
version: "10.2.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "10.1.0"
|
||||
version: "10.2.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.0.6"
|
||||
version: "9.0.7"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -435,14 +442,14 @@ packages:
|
||||
name: permission_handler_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
version: "0.1.2"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "5.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -457,6 +464,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.6.2"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -470,49 +484,21 @@ packages:
|
||||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "6.0.4"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
share_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
share_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "6.2.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
share_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
share_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.2.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -526,7 +512,7 @@ packages:
|
||||
name: shared_preferences_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
version: "2.0.14"
|
||||
shared_preferences_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -580,7 +566,7 @@ packages:
|
||||
name: source_span
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
version: "1.9.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -594,7 +580,7 @@ packages:
|
||||
name: stream_channel
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "2.1.0"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -615,7 +601,7 @@ packages:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.14"
|
||||
version: "0.4.12"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -643,7 +629,7 @@ packages:
|
||||
name: url_launcher_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.19"
|
||||
version: "6.0.21"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -686,13 +672,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.1.2"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -727,14 +720,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: workmanager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
version: "3.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -757,5 +743,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
||||
dart: ">=2.18.2 <3.0.0"
|
||||
flutter: ">=3.3.0"
|
||||
|
12
pubspec.yaml
@ -17,10 +17,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.5.8+29 # When changing this, update the tag in main() accordingly
|
||||
version: 0.7.0+56 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
@ -42,18 +42,20 @@ dependencies:
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
workmanager: ^0.5.0
|
||||
dynamic_color: ^1.5.4
|
||||
html: ^0.15.0
|
||||
shared_preferences: ^2.0.15
|
||||
url_launcher: ^6.1.5
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^5.0.5
|
||||
device_info_plus: ^8.0.0
|
||||
file_picker: ^5.1.0
|
||||
animations: ^2.0.4
|
||||
install_plugin_v2: ^1.0.0
|
||||
share_plus: ^4.4.0
|
||||
share_plus: ^6.0.1
|
||||
installed_apps: ^1.3.1
|
||||
package_archive_info: ^0.1.0
|
||||
android_alarm_manager_plus: ^2.1.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|