Compare commits
119 Commits
v0.1.5-bet
...
v0.6.9-bet
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
03f0b6cf05 | |||
5d8d0de8de | |||
07f6d4ad2c | |||
dfbb4e19a5 | |||
f5fda2ca90 | |||
661dc1626c | |||
dde3fc20fb | |||
017b867d8d | |||
1cb1c124eb | |||
fdeb852c7b | |||
67f50ba776 | |||
a0968caa5c | |||
e3e945d13b | |||
61f7f171b1 | |||
de07583161 | |||
49b9a65053 | |||
aebc8aed76 | |||
3958425c22 | |||
0a560871cb | |||
fbe4f0b49e | |||
e2440a38c4 | |||
496a10a444 | |||
b8bb8d1f4b | |||
af033f42cb | |||
e706661062 | |||
1a68b8abe6 | |||
15c0ed04d1 | |||
dd193d62f2 | |||
77e1768f3b | |||
da9e5aed5e | |||
136628c9e6 | |||
a916167be3 | |||
420cf487d4 | |||
12855370b0 | |||
33fed1cb2f | |||
33238b56a9 | |||
428c208de4 | |||
9a4b0301be | |||
f58d26524c | |||
45e5544c5b | |||
0a9373e65a | |||
b65c6e1d41 | |||
22dd8253a9 | |||
18198bbdfe | |||
cf3c86abb8 | |||
570e376742 | |||
32ae5e8175 | |||
cbf5057c17 | |||
2cfe62142a | |||
d03486fc5d | |||
224e435bbb | |||
90fa0e06ce | |||
6c1ad94b4f | |||
7d7986f8bf | |||
3ddf9ea736 | |||
2272f8b4e6 | |||
9514062a3a | |||
da57018b90 | |||
87e31c37aa | |||
cb4dfff1b9 | |||
911b06bfb6 | |||
53513bfdd1 | |||
681092d895 | |||
0f6b6253de | |||
c724b276ab | |||
35369273bd | |||
0b1863a227 | |||
9e21f2d6e6 | |||
6f11f850e0 | |||
5e96b91029 | |||
5fc79af960 | |||
05f5590e7d | |||
50f8caeb47 | |||
f966a9e626 | |||
02a5749ba7 | |||
4ccf7cbc92 | |||
ab4efd85ce | |||
42bba0f64c | |||
294327bde4 | |||
52b97662c6 | |||
f63da4b538 | |||
c30c692d87 | |||
d643d5a474 | |||
f8101a5d9f | |||
c2a7e4a0d2 | |||
285da7545b | |||
a5230acc11 | |||
53019818a6 | |||
1a04d39144 | |||
96c1ed612d | |||
4d75a6a361 | |||
30075add1c |
18
README.md
@ -4,19 +4,23 @@ Get Android App Updates Directly From the Source.
|
|||||||
|
|
||||||
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
|
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
|
||||||
|
|
||||||
Currently supported App sources:
|
|
||||||
- GitHub
|
|
||||||
- GitLab
|
|
||||||
|
|
||||||
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
||||||
|
|
||||||
|
Currently supported App sources:
|
||||||
|
- [GitHub](https://github.com/)
|
||||||
|
- [GitLab](https://gitlab.com/)
|
||||||
|
- [F-Droid](https://f-droid.org/)
|
||||||
|
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||||
|
- [Mullvad](https://mullvad.net/en/)
|
||||||
|
- [Signal](https://signal.org/)
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||||
- For GitHub, data is gathered using Web scraping and can easily break due to changes in website design. More reliable methods are either insufficient (GitHub RSS) or subject to rate limits (GitHub API). This may also apply to new sources added in the future.
|
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> |
|
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
|
||||||
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| <img src="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> |
|
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||||
|
@ -30,7 +30,25 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<service
|
||||||
|
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
|
||||||
|
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||||
|
android:exported="false"/>
|
||||||
|
<receiver
|
||||||
|
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
|
||||||
|
android:exported="false"/>
|
||||||
|
<receiver
|
||||||
|
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
</manifest>
|
</manifest>
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.0 KiB |
5
android/app/src/main/res/xml/file_paths.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-path path="Android/data/dev.imranr.obtainium/" name="files_root" />
|
||||||
|
<external-path path="." name="external_storage_root" />
|
||||||
|
</paths>
|
BIN
assets/graphics/banner.png
Executable file
After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
BIN
assets/graphics/icon.psd
Executable file
BIN
assets/graphics/obtainium.psd
Executable file
BIN
assets/graphics/store-icon.png
Executable file
After Width: | Height: | Size: 21 KiB |
BIN
assets/screenshots/1.apps.png
Normal file
After Width: | Height: | Size: 228 KiB |
BIN
assets/screenshots/2.dark_theme.png
Normal file
After Width: | Height: | Size: 162 KiB |
BIN
assets/screenshots/3.material_you.png
Normal file
After Width: | Height: | Size: 170 KiB |
BIN
assets/screenshots/4.app.png
Normal file
After Width: | Height: | Size: 146 KiB |
BIN
assets/screenshots/5.apk_picker.png
Normal file
After Width: | Height: | Size: 188 KiB |
BIN
assets/screenshots/6.apk_install.png
Normal file
After Width: | Height: | Size: 192 KiB |
89
lib/app_sources/fdroid.dart
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegExB =
|
||||||
|
RegExp('^https?://$host/+[^/]+/+packages/+[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegExB.firstMatch(url.toLowerCase());
|
||||||
|
if (match != null) {
|
||||||
|
url = 'https://$host/packages/${Uri.parse(url).pathSegments.last}';
|
||||||
|
}
|
||||||
|
RegExp standardUrlRegExA = RegExp('^https?://$host/+packages/+[^/]+');
|
||||||
|
match = standardUrlRegExA.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse(standardUrl));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var releases = parse(res.body).querySelectorAll('.package-version');
|
||||||
|
if (releases.isEmpty) {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
String? latestVersion = releases[0]
|
||||||
|
.querySelector('.package-version-header b')
|
||||||
|
?.innerHtml
|
||||||
|
.split(' ')
|
||||||
|
.sublist(1)
|
||||||
|
.join(' ');
|
||||||
|
if (latestVersion == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
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 NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
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 = [];
|
||||||
|
}
|
185
lib/app_sources/github.dart
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getCredentialPrefixIfAny() async {
|
||||||
|
SettingsProvider settingsProvider = SettingsProvider();
|
||||||
|
await settingsProvider.initializeSettings();
|
||||||
|
String? creds =
|
||||||
|
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
|
||||||
|
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
|
'$standardUrl/releases';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
var includePrereleases =
|
||||||
|
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||||
|
var fallbackToOlderReleases =
|
||||||
|
additionalData.length >= 2 && additionalData[1] == 'true';
|
||||||
|
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
|
||||||
|
? additionalData[2]
|
||||||
|
: null;
|
||||||
|
Response res = await get(Uri.parse(
|
||||||
|
'https://${await getCredentialPrefixIfAny()}api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||||
|
|
||||||
|
List<String> getReleaseAPKUrls(dynamic release) =>
|
||||||
|
(release['assets'] as List<dynamic>?)
|
||||||
|
?.map((e) {
|
||||||
|
return e['browser_download_url'] != null
|
||||||
|
? e['browser_download_url'] as String
|
||||||
|
: '';
|
||||||
|
})
|
||||||
|
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
dynamic targetRelease;
|
||||||
|
|
||||||
|
for (int i = 0; i < releases.length; i++) {
|
||||||
|
if (!fallbackToOlderReleases && i > 0) break;
|
||||||
|
if (!includePrereleases && releases[i]['prerelease'] == true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regexFilter != null &&
|
||||||
|
!RegExp(regexFilter)
|
||||||
|
.hasMatch((releases[i]['name'] as String).trim())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||||
|
if (apkUrls.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetRelease = releases[i];
|
||||||
|
targetRelease['apkUrls'] = apkUrls;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (targetRelease == null) {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
String? version = targetRelease['tag_name'];
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, targetRelease['apkUrls']);
|
||||||
|
} else {
|
||||||
|
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||||
|
throw RateLimitError(
|
||||||
|
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||||
|
60000000)
|
||||||
|
.round());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||||
|
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||||
|
return AppNames(names[0], names[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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),
|
||||||
|
))
|
||||||
|
])
|
||||||
|
];
|
||||||
|
}
|
84
lib/app_sources/gitlab.dart
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
|
'$standardUrl/-/releases';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var standardUri = Uri.parse(standardUrl);
|
||||||
|
var parsedHtml = parse(res.body);
|
||||||
|
var entry = parsedHtml.querySelector('entry');
|
||||||
|
var entryContent =
|
||||||
|
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||||
|
var apkUrlList = [
|
||||||
|
...getLinksFromParsedHTML(
|
||||||
|
entryContent,
|
||||||
|
RegExp(
|
||||||
|
'^${standardUri.path.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||||
|
return '\\${x[0]}';
|
||||||
|
})}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||||
|
caseSensitive: false),
|
||||||
|
standardUri.origin),
|
||||||
|
// GitLab releases may contain links to externally hosted APKs
|
||||||
|
...getLinksFromParsedHTML(entryContent,
|
||||||
|
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
||||||
|
.where((element) => Uri.parse(element).host != '')
|
||||||
|
.toList()
|
||||||
|
];
|
||||||
|
if (apkUrlList.isEmpty) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||||
|
var version =
|
||||||
|
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, apkUrlList);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
// Same as GitHub
|
||||||
|
return GitHub().getAppNames(standardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
75
lib/app_sources/izzyondroid.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse(standardUrl));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var parsedHtml = parse(res.body);
|
||||||
|
var multipleVersionApkUrls = parsedHtml
|
||||||
|
.querySelectorAll('a')
|
||||||
|
.where((element) =>
|
||||||
|
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
||||||
|
false)
|
||||||
|
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||||
|
.toList();
|
||||||
|
if (multipleVersionApkUrls.isEmpty) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
var version = parsedHtml
|
||||||
|
.querySelector('#keydata')
|
||||||
|
?.querySelectorAll('b')
|
||||||
|
.where(
|
||||||
|
(element) => element.innerHtml.toLowerCase().contains('version'))
|
||||||
|
.toList()[0]
|
||||||
|
.parentNode
|
||||||
|
?.parentNode
|
||||||
|
?.children[1]
|
||||||
|
.innerHtml;
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
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 = [];
|
||||||
|
}
|
62
lib/app_sources/mullvad.dart
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
|
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var version = parse(res.body)
|
||||||
|
.querySelector('p.subtitle.is-6')
|
||||||
|
?.querySelector('a')
|
||||||
|
?.attributes['href']
|
||||||
|
?.split('/')
|
||||||
|
.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(
|
||||||
|
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
54
lib/app_sources/signal.dart
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
return 'https://$host';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res =
|
||||||
|
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var json = jsonDecode(res.body);
|
||||||
|
String? apkUrl = json['url'];
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
String? version = json['versionName'];
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, [apkUrl]);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
78
lib/app_sources/sourceforge.dart
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw InvalidURLError(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var parsedHtml = parse(res.body);
|
||||||
|
var allDownloadLinks =
|
||||||
|
parsedHtml.querySelectorAll('guid').map((e) => e.innerHtml).toList();
|
||||||
|
getVersion(String url) {
|
||||||
|
try {
|
||||||
|
var tokens = url.split('/');
|
||||||
|
return tokens[tokens.length - 3];
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? version = getVersion(allDownloadLinks[0]);
|
||||||
|
if (version == null) {
|
||||||
|
throw NoVersionError();
|
||||||
|
}
|
||||||
|
var apkUrlListAllReleases = allDownloadLinks
|
||||||
|
.where((element) => element.toLowerCase().endsWith('.apk/download'))
|
||||||
|
.toList();
|
||||||
|
var apkUrlList =
|
||||||
|
apkUrlListAllReleases // This can be used skipped for fallback support later
|
||||||
|
.where((element) => getVersion(element) == version)
|
||||||
|
.toList();
|
||||||
|
if (apkUrlList.isEmpty) {
|
||||||
|
throw NoAPKError();
|
||||||
|
}
|
||||||
|
return APKDetails(version, apkUrlList);
|
||||||
|
} else {
|
||||||
|
throw NoReleasesError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames(runtimeType.toString(),
|
||||||
|
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
29
lib/components/custom_app_bar.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CustomAppBar extends StatefulWidget {
|
||||||
|
const CustomAppBar({super.key, required this.title});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomAppBar> createState() => _CustomAppBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomAppBarState extends State<CustomAppBar> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
expandedHeight: 100,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
style:
|
||||||
|
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
188
lib/components/generated_form.dart
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum FormItemType { string, bool }
|
||||||
|
|
||||||
|
typedef OnValueChanges = void Function(List<String> values, bool valid);
|
||||||
|
|
||||||
|
class GeneratedFormItem {
|
||||||
|
late String label;
|
||||||
|
late FormItemType type;
|
||||||
|
late bool required;
|
||||||
|
late int max;
|
||||||
|
late List<String? Function(String? value)> additionalValidators;
|
||||||
|
late String id;
|
||||||
|
late List<Widget> belowWidgets;
|
||||||
|
late String? hint;
|
||||||
|
|
||||||
|
GeneratedFormItem(
|
||||||
|
{this.label = 'Input',
|
||||||
|
this.type = FormItemType.string,
|
||||||
|
this.required = true,
|
||||||
|
this.max = 1,
|
||||||
|
this.additionalValidators = const [],
|
||||||
|
this.id = 'input',
|
||||||
|
this.belowWidgets = const [],
|
||||||
|
this.hint});
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeneratedForm extends StatefulWidget {
|
||||||
|
const GeneratedForm(
|
||||||
|
{super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.onValueChanges,
|
||||||
|
required this.defaultValues});
|
||||||
|
|
||||||
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final OnValueChanges onValueChanges;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeneratedForm> createState() => _GeneratedFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeneratedFormState extends State<GeneratedForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
late List<List<String>> values;
|
||||||
|
late List<List<Widget>> formInputs;
|
||||||
|
List<List<Widget>> rows = [];
|
||||||
|
|
||||||
|
// If any value changes, call this to update the parent with value and validity
|
||||||
|
void someValueChanged() {
|
||||||
|
List<String> returnValues = [];
|
||||||
|
var valid = true;
|
||||||
|
for (int r = 0; r < values.length; r++) {
|
||||||
|
for (int i = 0; i < values[r].length; i++) {
|
||||||
|
returnValues.add(values[r][i]);
|
||||||
|
if (formInputs[r][i] is TextFormField) {
|
||||||
|
valid = valid &&
|
||||||
|
((formInputs[r][i].key as GlobalKey<FormFieldState>)
|
||||||
|
.currentState
|
||||||
|
?.isValid ??
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widget.onValueChanges(returnValues, valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Initialize form values as all empty
|
||||||
|
int j = 0;
|
||||||
|
values = widget.items
|
||||||
|
.map((row) => row.map((e) {
|
||||||
|
return j < widget.defaultValues.length
|
||||||
|
? widget.defaultValues[j++]
|
||||||
|
: '';
|
||||||
|
}).toList())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Dynamically create form inputs
|
||||||
|
formInputs = widget.items.asMap().entries.map((row) {
|
||||||
|
return row.value.asMap().entries.map((e) {
|
||||||
|
if (e.value.type == FormItemType.string) {
|
||||||
|
final formFieldKey = GlobalKey<FormFieldState>();
|
||||||
|
return TextFormField(
|
||||||
|
key: formFieldKey,
|
||||||
|
initialValue: values[row.key][e.key],
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[row.key][e.key] = value;
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
helperText: e.value.label + (e.value.required ? ' *' : ''),
|
||||||
|
hintText: e.value.hint),
|
||||||
|
minLines: e.value.max <= 1 ? null : e.value.max,
|
||||||
|
maxLines: e.value.max <= 1 ? 1 : e.value.max,
|
||||||
|
validator: (value) {
|
||||||
|
if (e.value.required && (value == null || value.trim().isEmpty)) {
|
||||||
|
return '${e.value.label} (required)';
|
||||||
|
}
|
||||||
|
for (var validator in e.value.additionalValidators) {
|
||||||
|
String? result = validator(value);
|
||||||
|
if (result != null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Container(); // Some input types added in build
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
for (var r = 0; r < formInputs.length; r++) {
|
||||||
|
for (var e = 0; e < formInputs[r].length; e++) {
|
||||||
|
if (widget.items[r][e].type == FormItemType.bool) {
|
||||||
|
formInputs[r][e] = Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(widget.items[r][e].label),
|
||||||
|
Switch(
|
||||||
|
value: values[r][e] == 'true',
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
values[r][e] = value ? 'true' : '';
|
||||||
|
someValueChanged();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.clear();
|
||||||
|
formInputs.asMap().entries.forEach((rowInputs) {
|
||||||
|
if (rowInputs.key > 0) {
|
||||||
|
rows.add([
|
||||||
|
SizedBox(
|
||||||
|
height: widget.items[rowInputs.key][0].type == FormItemType.bool &&
|
||||||
|
widget.items[rowInputs.key - 1][0].type ==
|
||||||
|
FormItemType.string
|
||||||
|
? 25
|
||||||
|
: 8,
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
List<Widget> rowItems = [];
|
||||||
|
rowInputs.value.asMap().entries.forEach((rowInput) {
|
||||||
|
if (rowInput.key > 0) {
|
||||||
|
rowItems.add(const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
rowItems.add(Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
rowInput.value,
|
||||||
|
...widget.items[rowInputs.key][rowInput.key].belowWidgets
|
||||||
|
])));
|
||||||
|
});
|
||||||
|
rows.add(rowItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
...rows.map((row) => Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [...row.map((e) => e)],
|
||||||
|
))
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
76
lib/components/generated_form_modal.dart
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
|
||||||
|
class GeneratedFormModal extends StatefulWidget {
|
||||||
|
const GeneratedFormModal(
|
||||||
|
{super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.items,
|
||||||
|
required this.defaultValues,
|
||||||
|
this.initValid = false,
|
||||||
|
this.message = ''});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
final bool initValid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||||
|
List<String> values = [];
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
values = widget.defaultValues;
|
||||||
|
valid = widget.initValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(widget.title),
|
||||||
|
content:
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
if (widget.message.isNotEmpty) Text(widget.message),
|
||||||
|
if (widget.message.isNotEmpty)
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
GeneratedForm(
|
||||||
|
items: widget.items,
|
||||||
|
onValueChanges: (values, valid) {
|
||||||
|
setState(() {
|
||||||
|
this.values = values;
|
||||||
|
this.valid = valid;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
defaultValues: widget.defaultValues)
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: !valid
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (valid) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
Navigator.of(context).pop(values);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Continue'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
114
lib/custom_errors.dart
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
|
||||||
|
class ObtainiumError {
|
||||||
|
late String message;
|
||||||
|
ObtainiumError(this.message);
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RateLimitError {
|
||||||
|
late int remainingMinutes;
|
||||||
|
RateLimitError(this.remainingMinutes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
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 MultiAppMultiError extends ObtainiumError {
|
||||||
|
Map<String, List<String>> content = {};
|
||||||
|
|
||||||
|
MultiAppMultiError() : super('Multiple Errors Placeholder');
|
||||||
|
|
||||||
|
add(String appId, String string) {
|
||||||
|
var tempIds = content.remove(string);
|
||||||
|
tempIds ??= [];
|
||||||
|
tempIds.add(appId);
|
||||||
|
content.putIfAbsent(string, () => tempIds!);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
String finalString = '';
|
||||||
|
for (var e in content.keys) {
|
||||||
|
finalString += '$e: ${content[e].toString()}\n\n';
|
||||||
|
}
|
||||||
|
return finalString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(dynamic e, BuildContext context) {
|
||||||
|
if (e is String || (e is ObtainiumError && e is! MultiAppMultiError)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(e is MultiAppMultiError
|
||||||
|
? 'Some Errors Occurred'
|
||||||
|
: 'Unexpected Error'),
|
||||||
|
content: Text(e.toString()),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Ok')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String list2FriendlyString(List<String> list) {
|
||||||
|
return list.length == 2
|
||||||
|
? '${list[0]} and ${list[1]}'
|
||||||
|
: list
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) =>
|
||||||
|
e.value +
|
||||||
|
(e.key == list.length - 1
|
||||||
|
? ''
|
||||||
|
: e.key == list.length - 2
|
||||||
|
? ', and '
|
||||||
|
: ', '))
|
||||||
|
.join('');
|
||||||
|
}
|
170
lib/main.dart
@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/home.dart';
|
import 'package:obtainium/pages/home.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
@ -7,63 +11,114 @@ import 'package:obtainium/providers/settings_provider.dart';
|
|||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||||
|
|
||||||
|
const String currentVersion = '0.6.9';
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v0.1.5-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
|
const int bgUpdateCheckAlarmId = 666;
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
||||||
// Background update checking process
|
int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds'];
|
||||||
Workmanager().executeTask((task, taskName) async {
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
var appsProvider = AppsProvider(bg: true);
|
await AndroidAlarmManager.initialize();
|
||||||
var notificationsProvider = NotificationsProvider();
|
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||||
|
: null;
|
||||||
|
var notificationsProvider = NotificationsProvider();
|
||||||
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
|
try {
|
||||||
|
var appsProvider = AppsProvider(forBGTask: true);
|
||||||
|
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
|
await appsProvider.loadApps();
|
||||||
|
List<String> existingUpdateIds =
|
||||||
|
appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
|
DateTime nextIgnoreAfter = DateTime.now();
|
||||||
|
String? err;
|
||||||
try {
|
try {
|
||||||
await notificationsProvider
|
await appsProvider.checkUpdates(
|
||||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true);
|
||||||
await appsProvider.loadApps();
|
|
||||||
List<App> updates = await appsProvider.checkUpdates();
|
|
||||||
if (updates.isNotEmpty) {
|
|
||||||
notificationsProvider.notify(UpdateNotification(updates),
|
|
||||||
cancelExisting: true);
|
|
||||||
}
|
|
||||||
return Future.value(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notificationsProvider.notify(
|
if (e is RateLimitError || e is SocketException) {
|
||||||
ErrorCheckingUpdatesNotification(e.toString()),
|
AndroidAlarmManager.oneShot(
|
||||||
cancelExisting: true);
|
Duration(minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
||||||
return Future.value(false);
|
Random().nextInt(pow(2, 31) as int),
|
||||||
} finally {
|
bgUpdateCheck,
|
||||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
params: {
|
||||||
|
'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
err = e.toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
List<App> newUpdates = appsProvider
|
||||||
|
.findExistingUpdates(installedOnly: true)
|
||||||
|
.where((id) => !existingUpdateIds.contains(id))
|
||||||
|
.map((e) => appsProvider.apps[e]!.app)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// TODO: This silent update code doesn't work yet
|
||||||
|
// List<String> silentlyUpdated = await appsProvider
|
||||||
|
// .downloadAndInstallLatestApp(
|
||||||
|
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
|
||||||
|
// if (silentlyUpdated.isNotEmpty) {
|
||||||
|
// newUpdates = newUpdates
|
||||||
|
// .where((element) => !silentlyUpdated.contains(element.id))
|
||||||
|
// .toList();
|
||||||
|
// notificationsProvider.notify(
|
||||||
|
// SilentUpdateNotification(
|
||||||
|
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
|
||||||
|
// cancelExisting: true);
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (newUpdates.isNotEmpty) {
|
||||||
|
notificationsProvider.notify(UpdateNotification(newUpdates));
|
||||||
|
}
|
||||||
|
if (err != null) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notificationsProvider
|
||||||
|
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||||
|
} finally {
|
||||||
|
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) {
|
||||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
);
|
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
);
|
||||||
Workmanager().initialize(
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
bgTaskCallback,
|
}
|
||||||
);
|
await AndroidAlarmManager.initialize();
|
||||||
runApp(MultiProvider(
|
runApp(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider())
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const Obtainium(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultThemeColour = Colors.deepPurple;
|
var defaultThemeColour = Colors.deepPurple;
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class Obtainium extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const Obtainium({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Obtainium> createState() => _ObtainiumState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObtainiumState extends State<Obtainium> {
|
||||||
|
var existingUpdateInterval = -1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -71,30 +126,39 @@ class MyApp extends StatelessWidget {
|
|||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
|
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings().then((value) {
|
settingsProvider.initializeSettings();
|
||||||
// Delete past downloads and check for updates every time the app is launched
|
|
||||||
// Only runs once as the settings are only initialized once (so not on every build)
|
|
||||||
appsProvider.deleteSavedAPKs();
|
|
||||||
appsProvider.checkUpdates();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Register the background update task according to the user's setting
|
|
||||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
|
||||||
frequency: Duration(minutes: settingsProvider.updateInterval),
|
|
||||||
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
|
||||||
existingWorkPolicy: ExistingWorkPolicy.replace);
|
|
||||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||||
Permission.notification.request();
|
Permission.notification.request();
|
||||||
appsProvider.saveApp(App(
|
appsProvider.saveApps([
|
||||||
'imranr98_obtainium_${GitHub().host}',
|
App(
|
||||||
'https://github.com/ImranR98/Obtainium',
|
'dev.imranr.obtainium',
|
||||||
'ImranR98',
|
'https://github.com/ImranR98/Obtainium',
|
||||||
'Obtainium',
|
'ImranR98',
|
||||||
currentReleaseTag,
|
'Obtainium',
|
||||||
currentReleaseTag, []));
|
currentReleaseTag,
|
||||||
|
currentReleaseTag,
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
['true'],
|
||||||
|
null)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// Register the background update task according to the user's setting
|
||||||
|
if (existingUpdateInterval != settingsProvider.updateInterval) {
|
||||||
|
existingUpdateInterval = settingsProvider.updateInterval;
|
||||||
|
if (existingUpdateInterval == 0) {
|
||||||
|
AndroidAlarmManager.cancel(bgUpdateCheckAlarmId);
|
||||||
|
} else {
|
||||||
|
AndroidAlarmManager.periodic(
|
||||||
|
Duration(minutes: existingUpdateInterval),
|
||||||
|
bgUpdateCheckAlarmId,
|
||||||
|
bgUpdateCheck,
|
||||||
|
rescheduleOnReboot: true,
|
||||||
|
wakeup: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
51
lib/mass_app_sources/githubstars.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class GitHubStars implements MassAppUrlSource {
|
||||||
|
@override
|
||||||
|
late String name = 'GitHub Starred Repos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
late List<String> requiredArgs = ['Username'];
|
||||||
|
|
||||||
|
Future<List<String>> getOnePageOfUserStarredUrls(
|
||||||
|
String username, int page) async {
|
||||||
|
Response res = await get(Uri.parse(
|
||||||
|
'https://${await GitHub().getCredentialPrefixIfAny()}api.github.com/users/$username/starred?per_page=100&page=$page'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
return (jsonDecode(res.body) 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('Unable to find user\'s starred repos');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getUrls(List<String> args) async {
|
||||||
|
if (args.length != requiredArgs.length) {
|
||||||
|
throw ObtainiumError('Wrong number of arguments provided');
|
||||||
|
}
|
||||||
|
List<String> urls = [];
|
||||||
|
var page = 1;
|
||||||
|
while (true) {
|
||||||
|
var pageUrls = await getOnePageOfUserStarredUrls(args[0], page++);
|
||||||
|
urls.addAll(pageUrls);
|
||||||
|
if (pageUrls.length < 100) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -15,120 +18,206 @@ class AddAppPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AddAppPageState extends State<AddAppPage> {
|
class _AddAppPageState extends State<AddAppPage> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final urlInputController = TextEditingController();
|
|
||||||
bool gettingAppInfo = false;
|
bool gettingAppInfo = false;
|
||||||
|
|
||||||
|
String userInput = '';
|
||||||
|
AppSource? pickedSource;
|
||||||
|
List<String> additionalData = [];
|
||||||
|
bool validAdditionalData = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
return Center(
|
return Scaffold(
|
||||||
child: Form(
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
key: _formKey,
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
child: Column(
|
const CustomAppBar(title: 'Add App'),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
SliverFillRemaining(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Padding(
|
||||||
children: [
|
|
||||||
Container(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
Row(
|
||||||
decoration: const InputDecoration(
|
children: [
|
||||||
hintText: 'https://github.com/Author/Project',
|
Expanded(
|
||||||
helperText: 'Enter the App source URL'),
|
child: GeneratedForm(
|
||||||
controller: urlInputController,
|
items: [
|
||||||
validator: (value) {
|
[
|
||||||
if (value == null ||
|
GeneratedFormItem(
|
||||||
value.isEmpty ||
|
label: 'App Source Url',
|
||||||
Uri.tryParse(value) == null) {
|
additionalValidators: [
|
||||||
return 'Please enter a supported source URL';
|
(value) {
|
||||||
}
|
try {
|
||||||
return null;
|
sourceProvider
|
||||||
},
|
.getSource(value ?? '')
|
||||||
),
|
.standardizeURL(
|
||||||
Padding(
|
preStandardizeUrl(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
value ?? ''));
|
||||||
child: ElevatedButton(
|
} catch (e) {
|
||||||
onPressed: gettingAppInfo
|
return e is String
|
||||||
? null
|
? e
|
||||||
: () {
|
: 'Error';
|
||||||
HapticFeedback.mediumImpact();
|
}
|
||||||
if (_formKey.currentState!.validate()) {
|
return null;
|
||||||
setState(() {
|
}
|
||||||
gettingAppInfo = true;
|
])
|
||||||
});
|
]
|
||||||
sourceProvider
|
],
|
||||||
.getApp(urlInputController.value.text)
|
onValueChanges: (values, valid) {
|
||||||
.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.saveApp(app).then((_) {
|
|
||||||
urlInputController.clear();
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
AppPage(appId: app.id)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}).catchError((e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString())),
|
|
||||||
);
|
|
||||||
}).whenComplete(() {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
gettingAppInfo = false;
|
userInput = values[0];
|
||||||
|
var source = valid
|
||||||
|
? sourceProvider.getSource(userInput)
|
||||||
|
: null;
|
||||||
|
if (pickedSource != source) {
|
||||||
|
pickedSource = source;
|
||||||
|
additionalData = source != null
|
||||||
|
? source.additionalDataDefaults
|
||||||
|
: [];
|
||||||
|
validAdditionalData = source != null
|
||||||
|
? sourceProvider
|
||||||
|
.ifSourceAppsRequireAdditionalData(
|
||||||
|
source)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
}
|
defaultValues: const [])),
|
||||||
},
|
const SizedBox(
|
||||||
child: const Text('Add'),
|
width: 16,
|
||||||
|
),
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Add'))
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
if (pickedSource != null &&
|
||||||
],
|
pickedSource!.additionalDataDefaults.isNotEmpty)
|
||||||
),
|
Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
children: [
|
||||||
const Text(
|
const Divider(
|
||||||
'Supported Sources:',
|
height: 64,
|
||||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
),
|
||||||
// style: Theme.of(context).textTheme.bodySmall,
|
Text(
|
||||||
),
|
'Additional Options for ${pickedSource?.runtimeType}',
|
||||||
const SizedBox(
|
style: TextStyle(
|
||||||
height: 8,
|
color:
|
||||||
),
|
Theme.of(context).colorScheme.primary)),
|
||||||
...sourceProvider
|
const SizedBox(
|
||||||
.getSourceHosts()
|
height: 16,
|
||||||
.map((e) => GestureDetector(
|
),
|
||||||
onTap: () {
|
if (pickedSource!
|
||||||
launchUrlString('https://$e',
|
.additionalDataFormItems.isNotEmpty)
|
||||||
mode: LaunchMode.externalApplication);
|
GeneratedForm(
|
||||||
},
|
items: pickedSource!.additionalDataFormItems,
|
||||||
child: Text(
|
onValueChanges: (values, valid) {
|
||||||
e,
|
setState(() {
|
||||||
style: const TextStyle(
|
additionalData = values;
|
||||||
decoration: TextDecoration.underline,
|
validAdditionalData = valid;
|
||||||
fontStyle: FontStyle.italic),
|
});
|
||||||
)))
|
},
|
||||||
.toList()
|
defaultValues:
|
||||||
]),
|
pickedSource!.additionalDataDefaults),
|
||||||
if (gettingAppInfo)
|
if (pickedSource!
|
||||||
const LinearProgressIndicator()
|
.additionalDataFormItems.isNotEmpty)
|
||||||
else
|
const SizedBox(
|
||||||
Container(),
|
height: 8,
|
||||||
],
|
),
|
||||||
)),
|
],
|
||||||
);
|
)
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Supported Sources:',
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
...sourceProvider
|
||||||
|
.getSourceHosts()
|
||||||
|
.map((e) => GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://$e',
|
||||||
|
mode:
|
||||||
|
LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
e,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration:
|
||||||
|
TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic),
|
||||||
|
)))
|
||||||
|
.toList()
|
||||||
|
])),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/generated_form_modal.dart';
|
||||||
|
import 'package:obtainium/custom_errors.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@ -14,21 +19,115 @@ class AppPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppPageState extends State<AppPage> {
|
class _AppPageState extends State<AppPage> {
|
||||||
|
AppInMemory? prevApp;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
getUpdate(String id) {
|
||||||
|
appsProvider.checkUpdate(id).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceProvider = SourceProvider();
|
||||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||||
if (app?.app.installedVersion != null) {
|
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
|
||||||
appsProvider.getUpdate(app!.app.id);
|
if (!appsProvider.areDownloadsRunning() && prevApp == null && app != null) {
|
||||||
|
prevApp = app;
|
||||||
|
getUpdate(app.app.id);
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
|
||||||
title: Text('${app?.app.author}/${app?.app.name}'),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
),
|
body: RefreshIndicator(
|
||||||
body: WebView(
|
child: settingsProvider.showAppWebpage
|
||||||
initialUrl: app?.app.url,
|
? WebView(
|
||||||
javascriptMode: JavascriptMode.unrestricted,
|
backgroundColor: Theme.of(context).colorScheme.background,
|
||||||
),
|
initialUrl: app?.app.url,
|
||||||
|
javascriptMode: JavascriptMode.unrestricted,
|
||||||
|
)
|
||||||
|
: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Column(
|
||||||
|
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?.installedInfo?.name ?? app?.app.name ?? 'App',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'By ${app?.app.author ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (app?.app.url != null) {
|
||||||
|
launchUrlString(app?.app.url ?? '',
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
app?.app.url ?? '',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontSize: 12),
|
||||||
|
)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontStyle: FontStyle.italic, fontSize: 12),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onRefresh: () async {
|
||||||
|
if (app != null) {
|
||||||
|
getUpdate(app.app.id);
|
||||||
|
}
|
||||||
|
}),
|
||||||
bottomSheet: Padding(
|
bottomSheet: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||||
@ -40,22 +139,97 @@ class _AppPageState extends State<AppPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
|
if (app?.app.installedVersion != null &&
|
||||||
|
app?.app.installedVersion != app?.app.latestVersion)
|
||||||
|
IconButton(
|
||||||
|
onPressed: app?.downloadProgress != null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text(
|
||||||
|
'App Already up to Date?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: const Text('No')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback
|
||||||
|
.selectionClick();
|
||||||
|
var updatedApp = app?.app;
|
||||||
|
if (updatedApp != null) {
|
||||||
|
updatedApp
|
||||||
|
.installedVersion =
|
||||||
|
updatedApp
|
||||||
|
.latestVersion;
|
||||||
|
appsProvider.saveApps(
|
||||||
|
[updatedApp]);
|
||||||
|
}
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Yes, Mark as Updated'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Mark as Updated',
|
||||||
|
icon: const Icon(Icons.done)),
|
||||||
|
if (source != null &&
|
||||||
|
source.additionalDataFormItems.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
onPressed: app?.downloadProgress != null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog<List<String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Additional Options',
|
||||||
|
items: source
|
||||||
|
.additionalDataFormItems,
|
||||||
|
defaultValues: app != null
|
||||||
|
? app.app.additionalData
|
||||||
|
: source
|
||||||
|
.additionalDataDefaults);
|
||||||
|
}).then((values) {
|
||||||
|
if (app != null && values != null) {
|
||||||
|
var changedApp = app.app;
|
||||||
|
changedApp.additionalData = values;
|
||||||
|
appsProvider.saveApps(
|
||||||
|
[changedApp]).then((value) {
|
||||||
|
getUpdate(changedApp.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Additional Options',
|
||||||
|
icon: const Icon(Icons.settings)),
|
||||||
|
const SizedBox(width: 16.0),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (app?.app.installedVersion == null ||
|
onPressed: (app?.app.installedVersion == null ||
|
||||||
appsProvider
|
app?.app.installedVersion !=
|
||||||
.checkAppObjectForUpdate(
|
app?.app.latestVersion) &&
|
||||||
app!.app)) &&
|
|
||||||
!appsProvider.areDownloadsRunning()
|
!appsProvider.areDownloadsRunning()
|
||||||
? () {
|
? () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
appsProvider
|
appsProvider
|
||||||
.downloadAndInstallLatestApp(
|
.downloadAndInstallLatestApps(
|
||||||
[app!.app.id],
|
[app!.app.id],
|
||||||
context).then((res) {
|
context).then((res) {
|
||||||
if (res && mounted) {
|
if (res.isNotEmpty && mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@ -67,21 +241,20 @@ class _AppPageState extends State<AppPage> {
|
|||||||
onPressed: app?.downloadProgress != null
|
onPressed: app?.downloadProgress != null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Remove App?'),
|
title: const Text('Remove App?'),
|
||||||
content: Text(
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback
|
||||||
appsProvider
|
.selectionClick();
|
||||||
.removeApp(app!.app.id)
|
appsProvider.removeApps(
|
||||||
.then((_) {
|
[app!.app.id]).then((_) {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
Navigator.of(context)
|
Navigator.of(context)
|
||||||
.popUntil((_) =>
|
.popUntil((_) =>
|
||||||
@ -91,7 +264,6 @@ class _AppPageState extends State<AppPage> {
|
|||||||
child: const Text('Remove')),
|
child: const Text('Remove')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Cancel'))
|
child: const Text('Cancel'))
|
||||||
@ -100,8 +272,10 @@ class _AppPageState extends State<AppPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: Theme.of(context).errorColor,
|
foregroundColor:
|
||||||
surfaceTintColor: Theme.of(context).errorColor),
|
Theme.of(context).colorScheme.error,
|
||||||
|
surfaceTintColor:
|
||||||
|
Theme.of(context).colorScheme.error),
|
||||||
child: const Text('Remove'),
|
child: const Text('Remove'),
|
||||||
),
|
),
|
||||||
])),
|
])),
|
||||||
|
@ -1,82 +1,600 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/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/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class AppsPage extends StatefulWidget {
|
class AppsPage extends StatefulWidget {
|
||||||
const AppsPage({super.key});
|
const AppsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AppsPage> createState() => _AppsPageState();
|
State<AppsPage> createState() => AppsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppsPageState extends State<AppsPage> {
|
class AppsPageState extends State<AppsPage> {
|
||||||
|
AppsFilter? filter;
|
||||||
|
var updatesOnlyFilter =
|
||||||
|
AppsFilter(includeUptodate: false, includeNonInstalled: false);
|
||||||
|
Set<String> selectedIds = {};
|
||||||
|
DateTime? refreshingSince;
|
||||||
|
|
||||||
|
clearSelected() {
|
||||||
|
if (selectedIds.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
selectedIds.clear();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectThese(List<String> appIds) {
|
||||||
|
if (selectedIds.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
for (var a in appIds) {
|
||||||
|
selectedIds.add(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
var sortedApps = appsProvider.apps.values.toList();
|
||||||
|
var currentFilterIsUpdatesOnly =
|
||||||
|
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
|
||||||
|
|
||||||
|
selectedIds = selectedIds
|
||||||
|
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
toggleAppSelected(String appId) {
|
||||||
|
setState(() {
|
||||||
|
if (selectedIds.contains(appId)) {
|
||||||
|
selectedIds.remove(appId);
|
||||||
|
} else {
|
||||||
|
selectedIds.add(appId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter != null) {
|
||||||
|
sortedApps = sortedApps.where((app) {
|
||||||
|
if (app.app.installedVersion == app.app.latestVersion &&
|
||||||
|
!(filter!.includeUptodate)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (app.app.installedVersion == null &&
|
||||||
|
!(filter!.includeNonInstalled)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
List<String> nameTokens = filter!.nameFilter
|
||||||
|
.split(' ')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
List<String> authorTokens = filter!.authorFilter
|
||||||
|
.split(' ')
|
||||||
|
.where((element) => element.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (var t in nameTokens) {
|
||||||
|
var name = app.installedInfo?.name ?? app.app.name;
|
||||||
|
if (!name.toLowerCase().contains(t.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var t in authorTokens) {
|
||||||
|
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + nameA).compareTo(b.app.author + nameB);
|
||||||
|
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
||||||
|
result = (nameA + a.app.author).compareTo(nameB + b.app.author);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settingsProvider.sortOrder == SortOrderSettings.descending) {
|
||||||
|
sortedApps = sortedApps.reversed.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true);
|
||||||
|
|
||||||
|
var existingUpdateIdsAllOrSelected = existingUpdates
|
||||||
|
.where((element) => selectedIds.isEmpty
|
||||||
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
|
: selectedIds.contains(element))
|
||||||
|
.toList();
|
||||||
|
var newInstallIdsAllOrSelected = appsProvider
|
||||||
|
.findExistingUpdates(nonInstalledOnly: true)
|
||||||
|
.where((element) => selectedIds.isEmpty
|
||||||
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
|
: selectedIds.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];
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
? null
|
body: RefreshIndicator(
|
||||||
: ElevatedButton.icon(
|
onRefresh: () {
|
||||||
onPressed: appsProvider.areDownloadsRunning()
|
HapticFeedback.lightImpact();
|
||||||
? null
|
setState(() {
|
||||||
: () {
|
refreshingSince = DateTime.now();
|
||||||
HapticFeedback.heavyImpact();
|
});
|
||||||
context
|
return appsProvider.checkUpdates().catchError((e) {
|
||||||
.read<SettingsProvider>()
|
showError(e, context);
|
||||||
.getInstallPermission()
|
}).whenComplete(() {
|
||||||
.then((_) {
|
setState(() {
|
||||||
appsProvider.downloadAndInstallLatestApp(
|
refreshingSince = null;
|
||||||
existingUpdateAppIds, context);
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.update),
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
label: const Text('Update All')),
|
const CustomAppBar(title: 'Apps'),
|
||||||
body: Center(
|
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||||
child: appsProvider.loadingApps
|
SliverFillRemaining(
|
||||||
? const CircularProgressIndicator()
|
child: Center(
|
||||||
: appsProvider.apps.isEmpty
|
child: appsProvider.loadingApps
|
||||||
? Text(
|
? const CircularProgressIndicator()
|
||||||
'No Apps',
|
: Text(
|
||||||
style: Theme.of(context).textTheme.headline4,
|
appsProvider.apps.isEmpty
|
||||||
)
|
? 'No Apps'
|
||||||
: RefreshIndicator(
|
: 'No Apps for Filter',
|
||||||
onRefresh: () {
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
HapticFeedback.lightImpact();
|
textAlign: TextAlign.center,
|
||||||
return appsProvider.checkUpdates();
|
))),
|
||||||
},
|
if (refreshingSince != null)
|
||||||
child: ListView(
|
SliverToBoxAdapter(
|
||||||
children: appsProvider.apps.values
|
child: LinearProgressIndicator(
|
||||||
.map(
|
value: appsProvider.apps.values
|
||||||
(e) => ListTile(
|
.where((element) => !(element.app.lastUpdateCheck
|
||||||
title: Text('${e.app.author}/${e.app.name}'),
|
?.isBefore(refreshingSince!) ??
|
||||||
subtitle: Text(
|
true))
|
||||||
e.app.installedVersion ?? 'Not Installed'),
|
.length /
|
||||||
trailing: e.downloadProgress != null
|
appsProvider.apps.length,
|
||||||
? Text(
|
),
|
||||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
),
|
||||||
: (e.app.installedVersion != null &&
|
SliverList(
|
||||||
e.app.installedVersion !=
|
delegate: SliverChildBuilderDelegate(
|
||||||
e.app.latestVersion
|
(BuildContext context, int index) {
|
||||||
? const Text('Update Available')
|
return ListTile(
|
||||||
: null),
|
selectedTileColor:
|
||||||
onTap: () {
|
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||||
Navigator.push(
|
selected: selectedIds.contains(sortedApps[index].app.id),
|
||||||
context,
|
onLongPress: () {
|
||||||
MaterialPageRoute(
|
toggleAppSelected(sortedApps[index].app.id);
|
||||||
builder: (context) =>
|
},
|
||||||
AppPage(appId: e.app.id)),
|
leading: sortedApps[index].installedInfo != null
|
||||||
);
|
? Image.memory(
|
||||||
},
|
sortedApps[index].installedInfo!.icon!,
|
||||||
),
|
gaplessPlayback: true,
|
||||||
)
|
)
|
||||||
.toList(),
|
: null,
|
||||||
|
title: Text(sortedApps[index].installedInfo?.name ??
|
||||||
|
sortedApps[index].app.name),
|
||||||
|
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||||
|
trailing: sortedApps[index].downloadProgress != null
|
||||||
|
? Text(
|
||||||
|
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||||
|
: (sortedApps[index].app.installedVersion != null &&
|
||||||
|
sortedApps[index].app.installedVersion !=
|
||||||
|
sortedApps[index].app.latestVersion
|
||||||
|
? Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(appsProvider.areDownloadsRunning()
|
||||||
|
? 'Please Wait...'
|
||||||
|
: 'Update Available'),
|
||||||
|
SourceProvider()
|
||||||
|
.getSource(sortedApps[index].app.url)
|
||||||
|
.changeLogPageFromStandardUrl(
|
||||||
|
sortedApps[index].app.url) ==
|
||||||
|
null
|
||||||
|
? const SizedBox()
|
||||||
|
: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString(
|
||||||
|
SourceProvider()
|
||||||
|
.getSource(
|
||||||
|
sortedApps[index].app.url)
|
||||||
|
.changeLogPageFromStandardUrl(
|
||||||
|
sortedApps[index].app.url)!,
|
||||||
|
mode:
|
||||||
|
LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'See Changes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
decoration:
|
||||||
|
TextDecoration.underline),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: 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);
|
||||||
|
} else {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
AppPage(appId: sortedApps[index].app.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, childCount: sortedApps.length))
|
||||||
|
])),
|
||||||
|
persistentFooterButtons: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
selectedIds.isEmpty
|
||||||
|
? selectThese(sortedApps.map((e) => e.app.id).toList())
|
||||||
|
: clearSelected();
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
selectedIds.isEmpty
|
||||||
|
? Icons.select_all_outlined
|
||||||
|
: Icons.deselect_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
tooltip: selectedIds.isEmpty
|
||||||
|
? 'Select All'
|
||||||
|
: 'Deselect ${selectedIds.length.toString()}'),
|
||||||
|
const VerticalDivider(),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
selectedIds.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Remove Selected Apps?',
|
||||||
|
items: const [],
|
||||||
|
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.',
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
appsProvider.removeApps(selectedIds.toList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Remove Selected Apps',
|
||||||
|
icon: const Icon(Icons.delete_outline_outlined),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: appsProvider.areDownloadsRunning() ||
|
||||||
|
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||||
|
newInstallIdsAllOrSelected.isEmpty)
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
List<List<GeneratedFormItem>> formInputs = [];
|
||||||
|
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
|
||||||
|
newInstallIdsAllOrSelected.isNotEmpty) {
|
||||||
|
formInputs.add([
|
||||||
|
GeneratedFormItem(
|
||||||
|
label:
|
||||||
|
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]);
|
||||||
|
formInputs.add([
|
||||||
|
GeneratedFormItem(
|
||||||
|
label:
|
||||||
|
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title:
|
||||||
|
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||||
|
message:
|
||||||
|
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||||
|
items: formInputs,
|
||||||
|
defaultValues: [
|
||||||
|
'true',
|
||||||
|
existingUpdateIdsAllOrSelected.isEmpty
|
||||||
|
? 'true'
|
||||||
|
: ''
|
||||||
|
],
|
||||||
|
initValid: true,
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
bool shouldInstallUpdates = values[0] == 'true';
|
||||||
|
bool shouldInstallNew = values[1] == 'true';
|
||||||
|
settingsProvider
|
||||||
|
.getInstallPermission()
|
||||||
|
.then((_) {
|
||||||
|
List<String> toInstall = [];
|
||||||
|
if (shouldInstallUpdates) {
|
||||||
|
toInstall
|
||||||
|
.addAll(existingUpdateIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
if (shouldInstallNew) {
|
||||||
|
toInstall
|
||||||
|
.addAll(newInstallIdsAllOrSelected);
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.downloadAndInstallLatestApps(
|
||||||
|
toInstall, context)
|
||||||
|
.catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip:
|
||||||
|
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.file_download_outlined,
|
||||||
|
)),
|
||||||
|
selectedIds.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
content: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed:
|
||||||
|
appsProvider
|
||||||
|
.areDownloadsRunning()
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
'Mark ${selectedIds.length} Selected Apps as Updated?'),
|
||||||
|
content:
|
||||||
|
const Text(
|
||||||
|
'Only applies to installed but out of date Apps.'),
|
||||||
|
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;
|
||||||
|
if (a.installedVersion !=
|
||||||
|
null) {
|
||||||
|
a.installedVersion = a.latestVersion;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}).toList());
|
||||||
|
|
||||||
|
Navigator.of(context)
|
||||||
|
.pop();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Yes'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip:
|
||||||
|
'Mark Selected Apps as Updated',
|
||||||
|
icon: const Icon(Icons.done)),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
String urls = '';
|
||||||
|
for (var id in selectedIds) {
|
||||||
|
urls +=
|
||||||
|
'${appsProvider.apps[id]!.app.url}\n';
|
||||||
|
}
|
||||||
|
urls = urls.substring(
|
||||||
|
0, urls.length - 1);
|
||||||
|
Share.share(urls,
|
||||||
|
subject:
|
||||||
|
'${selectedIds.length} Selected App URLs from Obtainium');
|
||||||
|
},
|
||||||
|
tooltip: 'Share Selected App URLs',
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'More',
|
||||||
|
icon: const Icon(Icons.more_horiz),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
const VerticalDivider(),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
if (currentFilterIsUpdatesOnly) {
|
||||||
|
filter = null;
|
||||||
|
} else {
|
||||||
|
filter = updatesOnlyFilter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: currentFilterIsUpdatesOnly
|
||||||
|
? 'Remove Out-of-Date App Filter'
|
||||||
|
: 'Show Out-of-Date Apps Only',
|
||||||
|
icon: Icon(
|
||||||
|
currentFilterIsUpdatesOnly
|
||||||
|
? Icons.update_disabled_rounded
|
||||||
|
: Icons.update_rounded,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
appsProvider.apps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: TextButton.icon(
|
||||||
|
label: Text(
|
||||||
|
filter == null ? 'Filter' : 'Filter *',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: filter == null
|
||||||
|
? FontWeight.normal
|
||||||
|
: FontWeight.bold),
|
||||||
),
|
),
|
||||||
));
|
onPressed: () {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Filter Apps',
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: 'App Name', required: false),
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: 'Author', required: false)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: 'Up to Date Apps',
|
||||||
|
type: FormItemType.bool)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: 'Non-Installed Apps',
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: filter == null
|
||||||
|
? AppsFilter().toValuesArray()
|
||||||
|
: filter!.toValuesArray());
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
setState(() {
|
||||||
|
filter = AppsFilter.fromValuesArray(values);
|
||||||
|
if (AppsFilter().isIdenticalTo(filter!)) {
|
||||||
|
filter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.filter_list_rounded))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppsFilter {
|
||||||
|
late String nameFilter;
|
||||||
|
late String authorFilter;
|
||||||
|
late bool includeUptodate;
|
||||||
|
late bool includeNonInstalled;
|
||||||
|
|
||||||
|
AppsFilter(
|
||||||
|
{this.nameFilter = '',
|
||||||
|
this.authorFilter = '',
|
||||||
|
this.includeUptodate = true,
|
||||||
|
this.includeNonInstalled = true});
|
||||||
|
|
||||||
|
List<String> toValuesArray() {
|
||||||
|
return [
|
||||||
|
nameFilter,
|
||||||
|
authorFilter,
|
||||||
|
includeUptodate ? 'true' : '',
|
||||||
|
includeNonInstalled ? 'true' : ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
AppsFilter.fromValuesArray(List<String> values) {
|
||||||
|
nameFilter = values[0];
|
||||||
|
authorFilter = values[1];
|
||||||
|
includeUptodate = values[2] == 'true';
|
||||||
|
includeNonInstalled = values[3] == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isIdenticalTo(AppsFilter other) =>
|
||||||
|
authorFilter.trim() == other.authorFilter.trim() &&
|
||||||
|
nameFilter.trim() == other.nameFilter.trim() &&
|
||||||
|
includeUptodate == other.includeUptodate &&
|
||||||
|
includeNonInstalled == other.includeNonInstalled;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import 'package:animations/animations.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/add_app.dart';
|
import 'package:obtainium/pages/add_app.dart';
|
||||||
import 'package:obtainium/pages/apps.dart';
|
import 'package:obtainium/pages/apps.dart';
|
||||||
|
import 'package:obtainium/pages/import_export.dart';
|
||||||
import 'package:obtainium/pages/settings.dart';
|
import 'package:obtainium/pages/settings.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
@ -11,44 +13,85 @@ class HomePage extends StatefulWidget {
|
|||||||
State<HomePage> createState() => _HomePageState();
|
State<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class NavigationPageItem {
|
||||||
|
late String title;
|
||||||
|
late IconData icon;
|
||||||
|
late Widget widget;
|
||||||
|
|
||||||
|
NavigationPageItem(this.title, this.icon, this.widget);
|
||||||
|
}
|
||||||
|
|
||||||
class _HomePageState extends State<HomePage> {
|
class _HomePageState extends State<HomePage> {
|
||||||
int selectedIndex = 1;
|
List<int> selectedIndexHistory = [];
|
||||||
List<Widget> pages = [
|
|
||||||
const SettingsPage(),
|
List<NavigationPageItem> pages = [
|
||||||
const AppsPage(),
|
NavigationPageItem(
|
||||||
const AddAppPage()
|
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
|
||||||
|
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
|
||||||
|
NavigationPageItem(
|
||||||
|
'Import/Export', Icons.import_export, const ImportExportPage()),
|
||||||
|
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return WillPopScope(
|
return WillPopScope(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(title: const Text('Obtainium')),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: pages.elementAt(selectedIndex),
|
body: PageTransitionSwitcher(
|
||||||
|
transitionBuilder: (
|
||||||
|
Widget child,
|
||||||
|
Animation<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
) {
|
||||||
|
return SharedAxisTransition(
|
||||||
|
animation: animation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: pages
|
||||||
|
.elementAt(selectedIndexHistory.isEmpty
|
||||||
|
? 0
|
||||||
|
: selectedIndexHistory.last)
|
||||||
|
.widget,
|
||||||
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
destinations: const [
|
destinations: pages
|
||||||
NavigationDestination(
|
.map((e) =>
|
||||||
icon: Icon(Icons.settings), label: 'Settings'),
|
NavigationDestination(icon: Icon(e.icon), label: e.title))
|
||||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
.toList(),
|
||||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
|
||||||
],
|
|
||||||
onDestinationSelected: (int index) {
|
onDestinationSelected: (int index) {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.selectionClick();
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedIndex = index;
|
if (index == 0) {
|
||||||
|
selectedIndexHistory.clear();
|
||||||
|
} else if (selectedIndexHistory.isEmpty ||
|
||||||
|
(selectedIndexHistory.isNotEmpty &&
|
||||||
|
selectedIndexHistory.last != index)) {
|
||||||
|
int existingInd = selectedIndexHistory.indexOf(index);
|
||||||
|
if (existingInd >= 0) {
|
||||||
|
selectedIndexHistory.removeAt(existingInd);
|
||||||
|
}
|
||||||
|
selectedIndexHistory.add(index);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex:
|
||||||
|
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onWillPop: () async {
|
onWillPop: () async {
|
||||||
if (selectedIndex != 1) {
|
if (selectedIndexHistory.isNotEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedIndex = 1;
|
selectedIndexHistory.removeLast();
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
|
||||||
|
.currentState
|
||||||
|
?.clearSelected();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
442
lib/pages/import_export.dart
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
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/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';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
||||||
|
class ImportExportPage extends StatefulWidget {
|
||||||
|
const ImportExportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImportExportPage> createState() => _ImportExportPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportExportPageState extends State<ImportExportPage> {
|
||||||
|
bool importInProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
|
var settingsProvider = context.read<SettingsProvider>();
|
||||||
|
var appsProvider = context.read<AppsProvider>();
|
||||||
|
var outlineButtonStyle = ButtonStyle(
|
||||||
|
shape: MaterialStateProperty.all(
|
||||||
|
StadiumBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
width: 1,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<List<List<String>>> addApps(List<String> urls) async {
|
||||||
|
await settingsProvider.getInstallPermission();
|
||||||
|
List<dynamic> results = await sourceProvider.getApps(urls,
|
||||||
|
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
|
||||||
|
List<App> apps = results[0];
|
||||||
|
Map<String, dynamic> errorsMap = results[1];
|
||||||
|
for (var app in apps) {
|
||||||
|
if (appsProvider.apps.containsKey(app.id)) {
|
||||||
|
errorsMap.addAll({app.id: 'App already added'});
|
||||||
|
} else {
|
||||||
|
await appsProvider.saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<List<String>> errors =
|
||||||
|
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
|
const CustomAppBar(title: 'Import/Export'),
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
style: outlineButtonStyle,
|
||||||
|
onPressed: appsProvider.apps.isEmpty ||
|
||||||
|
importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
appsProvider
|
||||||
|
.exportApps()
|
||||||
|
.then((String path) {
|
||||||
|
showError(
|
||||||
|
'Exported to $path', context);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Obtainium Export'))),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
style: outlineButtonStyle,
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
FilePicker.platform
|
||||||
|
.pickFiles()
|
||||||
|
.then((result) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
if (result != null) {
|
||||||
|
String data = File(
|
||||||
|
result.files.single.path!)
|
||||||
|
.readAsStringSync();
|
||||||
|
try {
|
||||||
|
jsonDecode(data);
|
||||||
|
} catch (e) {
|
||||||
|
throw ObtainiumError(
|
||||||
|
'Invalid input');
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.importApps(data)
|
||||||
|
.then((value) {
|
||||||
|
showError(
|
||||||
|
'$value App${value == 1 ? '' : 's'} Imported',
|
||||||
|
context);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User canceled the picker
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Obtainium Import')))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (importInProgress)
|
||||||
|
Column(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
LinearProgressIndicator(),
|
||||||
|
SizedBox(
|
||||||
|
height: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Divider(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: importInProgress
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Import from URL List',
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormItem(
|
||||||
|
label: 'App URL List',
|
||||||
|
max: 7,
|
||||||
|
additionalValidators: [
|
||||||
|
(String? value) {
|
||||||
|
if (value != null &&
|
||||||
|
value.isNotEmpty) {
|
||||||
|
var lines = value
|
||||||
|
.trim()
|
||||||
|
.split('\n');
|
||||||
|
for (int i = 0;
|
||||||
|
i < lines.length;
|
||||||
|
i++) {
|
||||||
|
try {
|
||||||
|
sourceProvider
|
||||||
|
.getSource(
|
||||||
|
lines[i]);
|
||||||
|
} catch (e) {
|
||||||
|
return 'Line ${i + 1}: $e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: const [],
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
var urls =
|
||||||
|
(values[0] as String).split('\n');
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
addApps(urls).then((errors) {
|
||||||
|
if (errors.isEmpty) {
|
||||||
|
showError(
|
||||||
|
'Imported ${urls.length} Apps',
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength: urls.length,
|
||||||
|
errors: errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
showError(e, context);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Import from URL List',
|
||||||
|
)),
|
||||||
|
...sourceProvider.massUrlSources
|
||||||
|
.map((source) => Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
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) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = true;
|
||||||
|
});
|
||||||
|
source
|
||||||
|
.getUrls(values)
|
||||||
|
.then((urls) {
|
||||||
|
showDialog<List<String>?>(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return UrlSelectionModal(
|
||||||
|
urls: urls);
|
||||||
|
})
|
||||||
|
.then((selectedUrls) {
|
||||||
|
if (selectedUrls !=
|
||||||
|
null) {
|
||||||
|
addApps(selectedUrls)
|
||||||
|
.then((errors) {
|
||||||
|
if (errors
|
||||||
|
.isEmpty) {
|
||||||
|
showError(
|
||||||
|
'Imported ${selectedUrls.length} Apps',
|
||||||
|
context);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context:
|
||||||
|
context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength:
|
||||||
|
selectedUrls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catchError((e) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
showError(e, context);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text('Import ${source.name}'))
|
||||||
|
]))
|
||||||
|
.toList()
|
||||||
|
],
|
||||||
|
)))
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportErrorDialog extends StatefulWidget {
|
||||||
|
const ImportErrorDialog(
|
||||||
|
{super.key, required this.urlsLength, required this.errors});
|
||||||
|
|
||||||
|
final int urlsLength;
|
||||||
|
final List<List<String>> errors;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Import Errors'),
|
||||||
|
content:
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
Text(
|
||||||
|
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'The following URLs had errors:',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
...widget.errors.map((e) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Text(e[0]),
|
||||||
|
Text(
|
||||||
|
e[1],
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}).toList()
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Okay'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: must_be_immutable
|
||||||
|
class UrlSelectionModal extends StatefulWidget {
|
||||||
|
UrlSelectionModal({super.key, required this.urls});
|
||||||
|
|
||||||
|
List<String> urls;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||||
|
Map<String, bool> urlSelections = {};
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
for (var url in widget.urls) {
|
||||||
|
urlSelections.putIfAbsent(url, () => true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Select URLs to Import'),
|
||||||
|
content: Column(children: [
|
||||||
|
...urlSelections.keys.map((url) {
|
||||||
|
return Row(children: [
|
||||||
|
Checkbox(
|
||||||
|
value: urlSelections[url],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
urlSelections[url] = value ?? false;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
width: 8,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
Uri.parse(url).path.substring(1),
|
||||||
|
))
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(urlSelections.keys
|
||||||
|
.where((url) => urlSelections[url] ?? false)
|
||||||
|
.toList());
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Import ${urlSelections.values.where((b) => b).length} URLs'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,8 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
@ -17,230 +16,250 @@ class SettingsPage extends StatefulWidget {
|
|||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
|
||||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings();
|
settingsProvider.initializeSettings();
|
||||||
}
|
}
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: settingsProvider.prefs == null
|
|
||||||
? Container()
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
DropdownButtonFormField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Background Update Checking Interval'),
|
|
||||||
value: settingsProvider.updateInterval,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 15,
|
|
||||||
child: Text('15 Minutes'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 30,
|
|
||||||
child: Text('30 Minutes'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 60,
|
|
||||||
child: Text('1 Hour'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 360,
|
|
||||||
child: Text('6 Hours'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 720,
|
|
||||||
child: Text('12 Hours'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 1440,
|
|
||||||
child: Text('1 Day'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
settingsProvider.updateInterval = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
const SizedBox(
|
|
||||||
height: 32,
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: appsProvider.apps.isEmpty
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
appsProvider.exportApps().then((String path) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Exported to $path')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Export Apps')),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
final formKey = GlobalKey<FormState>();
|
|
||||||
final jsonInputController =
|
|
||||||
TextEditingController();
|
|
||||||
|
|
||||||
return AlertDialog(
|
var themeDropdown = DropdownButtonFormField(
|
||||||
scrollable: true,
|
decoration: const InputDecoration(labelText: 'Theme'),
|
||||||
title: const Text('Import Apps'),
|
value: settingsProvider.theme,
|
||||||
content: Column(children: [
|
items: const [
|
||||||
const Text(
|
DropdownMenuItem(
|
||||||
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
value: ThemeSettings.dark,
|
||||||
Form(
|
child: Text('Dark'),
|
||||||
key: formKey,
|
),
|
||||||
child: TextFormField(
|
DropdownMenuItem(
|
||||||
minLines: 7,
|
value: ThemeSettings.light,
|
||||||
maxLines: 7,
|
child: Text('Light'),
|
||||||
decoration: const InputDecoration(
|
),
|
||||||
helperText:
|
DropdownMenuItem(
|
||||||
'Obtainium export data'),
|
value: ThemeSettings.system,
|
||||||
controller: jsonInputController,
|
child: Text('Follow System'),
|
||||||
validator: (value) {
|
)
|
||||||
if (value == null ||
|
],
|
||||||
value.isEmpty) {
|
onChanged: (value) {
|
||||||
return 'Please enter your Obtainium export data';
|
if (value != null) {
|
||||||
}
|
settingsProvider.theme = value;
|
||||||
bool isJSON = true;
|
}
|
||||||
try {
|
});
|
||||||
jsonDecode(value);
|
|
||||||
} catch (e) {
|
var colourDropdown = DropdownButtonFormField(
|
||||||
isJSON = false;
|
decoration: const InputDecoration(labelText: 'Colour'),
|
||||||
}
|
value: settingsProvider.colour,
|
||||||
if (!isJSON) {
|
items: const [
|
||||||
return 'Invalid input';
|
DropdownMenuItem(
|
||||||
}
|
value: ColourSettings.basic,
|
||||||
return null;
|
child: Text('Obtainium'),
|
||||||
},
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
)
|
value: ColourSettings.materialYou,
|
||||||
]),
|
child: Text('Material You'),
|
||||||
actions: [
|
)
|
||||||
TextButton(
|
],
|
||||||
onPressed: () {
|
onChanged: (value) {
|
||||||
HapticFeedback.lightImpact();
|
if (value != null) {
|
||||||
Navigator.of(context).pop();
|
settingsProvider.colour = value;
|
||||||
},
|
}
|
||||||
child: const Text('Cancel')),
|
});
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
var sortDropdown = DropdownButtonFormField(
|
||||||
HapticFeedback.heavyImpact();
|
decoration: const InputDecoration(labelText: 'App Sort By'),
|
||||||
if (formKey.currentState!
|
value: settingsProvider.sortColumn,
|
||||||
.validate()) {
|
items: const [
|
||||||
appsProvider
|
DropdownMenuItem(
|
||||||
.importApps(
|
value: SortColumnSettings.authorName,
|
||||||
jsonInputController
|
child: Text('Author/Name'),
|
||||||
.value.text)
|
),
|
||||||
.then((value) {
|
DropdownMenuItem(
|
||||||
ScaffoldMessenger.of(context)
|
value: SortColumnSettings.nameAuthor,
|
||||||
.showSnackBar(
|
child: Text('Name/Author'),
|
||||||
SnackBar(
|
),
|
||||||
content: Text(
|
DropdownMenuItem(
|
||||||
'$value Apps Imported')),
|
value: SortColumnSettings.added,
|
||||||
);
|
child: Text('As Added'),
|
||||||
}).catchError((e) {
|
)
|
||||||
ScaffoldMessenger.of(context)
|
],
|
||||||
.showSnackBar(
|
onChanged: (value) {
|
||||||
SnackBar(
|
if (value != null) {
|
||||||
content:
|
settingsProvider.sortColumn = value;
|
||||||
Text(e.toString())),
|
}
|
||||||
);
|
});
|
||||||
}).whenComplete(() {
|
|
||||||
Navigator.of(context).pop();
|
var orderDropdown = DropdownButtonFormField(
|
||||||
});
|
decoration: const InputDecoration(labelText: 'App Sort Order'),
|
||||||
}
|
value: settingsProvider.sortOrder,
|
||||||
},
|
items: const [
|
||||||
child: const Text('Import')),
|
DropdownMenuItem(
|
||||||
],
|
value: SortOrderSettings.ascending,
|
||||||
);
|
child: Text('Ascending'),
|
||||||
});
|
),
|
||||||
},
|
DropdownMenuItem(
|
||||||
child: const Text('Import Apps'))
|
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>[
|
||||||
|
const CustomAppBar(title: 'Settings'),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: settingsProvider.prefs == null
|
||||||
|
? const SizedBox()
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Appearance',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
themeDropdown,
|
||||||
|
height16,
|
||||||
|
colourDropdown,
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: sortDropdown),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
Expanded(child: orderDropdown),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Show Source Webpage in App View'),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.showAppWebpage,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.showAppWebpage = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
height16,
|
||||||
|
Text(
|
||||||
|
'Updates',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
intervalDropdown,
|
||||||
|
const Divider(
|
||||||
|
height: 48,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Source-Specific',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
...sourceSpecificFields,
|
||||||
|
],
|
||||||
|
))),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
height16,
|
||||||
|
TextButton.icon(
|
||||||
|
style: ButtonStyle(
|
||||||
|
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||||
|
(Set<MaterialState> states) {
|
||||||
|
return Colors.grey;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
onPressed: () {
|
||||||
Row(
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mode: LaunchMode.externalApplication);
|
||||||
children: [
|
},
|
||||||
TextButton.icon(
|
icon: const Icon(Icons.code),
|
||||||
style: ButtonStyle(
|
label: Text(
|
||||||
foregroundColor:
|
'Source',
|
||||||
MaterialStateProperty.resolveWith<Color>(
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
(Set<MaterialState> states) {
|
|
||||||
return Colors.grey;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
|
||||||
mode: LaunchMode.externalApplication);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.code),
|
|
||||||
label: Text(
|
|
||||||
'Source',
|
|
||||||
style: Theme.of(context).textTheme.caption,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
));
|
height16,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,27 +5,35 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||||
|
import 'package: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:obtainium/providers/notifications_provider.dart';
|
||||||
|
import 'package:package_archive_info/package_archive_info.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
|
||||||
|
|
||||||
class AppInMemory {
|
class AppInMemory {
|
||||||
late App app;
|
late App app;
|
||||||
double? downloadProgress;
|
double? downloadProgress;
|
||||||
|
AppInfo? installedInfo;
|
||||||
|
|
||||||
AppInMemory(this.app, this.downloadProgress);
|
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApkFile {
|
class DownloadedApk {
|
||||||
String appId;
|
String appId;
|
||||||
File file;
|
File file;
|
||||||
ApkFile(this.appId, this.file);
|
DownloadedApk(this.appId, this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppsProvider with ChangeNotifier {
|
class AppsProvider with ChangeNotifier {
|
||||||
@ -33,105 +41,303 @@ class AppsProvider with ChangeNotifier {
|
|||||||
Map<String, AppInMemory> apps = {};
|
Map<String, AppInMemory> apps = {};
|
||||||
bool loadingApps = false;
|
bool loadingApps = false;
|
||||||
bool gettingUpdates = false;
|
bool gettingUpdates = false;
|
||||||
|
bool forBGTask = false;
|
||||||
|
|
||||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||||
bool isForeground = true;
|
bool isForeground = true;
|
||||||
late Stream<FGBGType> foregroundStream;
|
late Stream<FGBGType>? foregroundStream;
|
||||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider({bool bg = false}) {
|
AppsProvider({this.forBGTask = false}) {
|
||||||
// Subscribe to changes in the app foreground status
|
// Many setup tasks should only be done in the foreground isolate
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
if (!forBGTask) {
|
||||||
foregroundSubscription = foregroundStream.listen((event) async {
|
// Subscribe to changes in the app foreground status
|
||||||
isForeground = event == FGBGType.foreground;
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
if (isForeground) await loadApps();
|
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||||
});
|
isForeground = event == FGBGType.foreground;
|
||||||
loadApps();
|
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 {
|
downloadFile(String url, String fileName, Function? onProgress) async {
|
||||||
|
var destDir = (await getExternalStorageDirectory())!.path;
|
||||||
StreamedResponse response =
|
StreamedResponse response =
|
||||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
await Client().send(Request('GET', Uri.parse(url)));
|
||||||
File downloadFile =
|
File downloadedFile = File('$destDir/$fileName');
|
||||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
|
||||||
if (downloadFile.existsSync()) {
|
if (downloadedFile.existsSync()) {
|
||||||
downloadFile.deleteSync();
|
downloadedFile.deleteSync();
|
||||||
}
|
}
|
||||||
var length = response.contentLength;
|
var length = response.contentLength;
|
||||||
var received = 0;
|
var received = 0;
|
||||||
var sink = downloadFile.openWrite();
|
double? progress;
|
||||||
|
var sink = downloadedFile.openWrite();
|
||||||
|
|
||||||
await response.stream.map((s) {
|
await response.stream.map((s) {
|
||||||
received += s.length;
|
received += s.length;
|
||||||
apps[appId]!.downloadProgress =
|
progress = (length != null ? received / length * 100 : 30);
|
||||||
(length != null ? received / length * 100 : 30);
|
if (onProgress != null) {
|
||||||
notifyListeners();
|
onProgress(progress);
|
||||||
|
}
|
||||||
return s;
|
return s;
|
||||||
}).pipe(sink);
|
}).pipe(sink);
|
||||||
|
|
||||||
await sink.close();
|
await sink.close();
|
||||||
apps[appId]!.downloadProgress = null;
|
progress = null;
|
||||||
notifyListeners();
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
downloadFile.deleteSync();
|
downloadedFile.deleteSync();
|
||||||
throw response.reasonPhrase ?? 'Unknown Error';
|
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) {
|
||||||
|
throw IDChangedError();
|
||||||
|
}
|
||||||
|
app.id = newInfo.packageName;
|
||||||
|
downloadedFile = downloadedFile.renameSync(
|
||||||
|
'${downloadedFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||||
|
}
|
||||||
|
return DownloadedApk(app.id, downloadedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool areDownloadsRunning() => apps.values
|
bool areDownloadsRunning() => apps.values
|
||||||
.where((element) => element.downloadProgress != null)
|
.where((element) => element.downloadProgress != null)
|
||||||
.isNotEmpty;
|
.isNotEmpty;
|
||||||
|
|
||||||
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
Future<bool> canInstallSilently(App app) async {
|
||||||
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
return false;
|
||||||
// Returns upon successful download, regardless of installation result
|
// TODO: Uncomment the below once silentupdates are ever figured out
|
||||||
Future<bool> downloadAndInstallLatestApp(
|
// // TODO: This is unreliable - try to get from OS in the future
|
||||||
List<String> appIds, BuildContext context) async {
|
// 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> waitForUserToReturnToForeground(BuildContext context) async {
|
||||||
NotificationsProvider notificationsProvider =
|
NotificationsProvider notificationsProvider =
|
||||||
context.read<NotificationsProvider>();
|
context.read<NotificationsProvider>();
|
||||||
Map<String, String> appsToInstall = {};
|
|
||||||
for (var id in appIds) {
|
|
||||||
if (apps[id] == null) {
|
|
||||||
throw 'App not found';
|
|
||||||
}
|
|
||||||
String? apkUrl = apps[id]!.app.apkUrls.last;
|
|
||||||
if (apps[id]!.app.apkUrls.length > 1) {
|
|
||||||
apkUrl = await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (apkUrl != null) {
|
|
||||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
|
||||||
.map((entry) => downloadApp(entry.value, entry.key)));
|
|
||||||
|
|
||||||
if (!isForeground) {
|
if (!isForeground) {
|
||||||
await notificationsProvider.notify(completeInstallationNotification,
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
cancelExisting: true);
|
cancelExisting: true);
|
||||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
while (await FGBGEvents.stream.first != FGBGType.foreground) {}
|
||||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
// We need to wait for the App to come to the foreground to install it
|
|
||||||
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
|
||||||
// https://github.com/flutter/flutter/issues/13937
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
||||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||||
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
|
// 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(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;
|
||||||
|
// 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
|
||||||
|
// If the APKs can be installed silently, they are
|
||||||
|
// 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>> downloadAndInstallLatestApps(
|
||||||
|
List<String> appIds, BuildContext? context) async {
|
||||||
|
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 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)) {
|
||||||
|
appsToInstall.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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) {
|
for (var f in downloadedFiles) {
|
||||||
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium');
|
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||||
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
if (willBeSilent) {
|
||||||
await saveApp(apps[f.appId]!.app);
|
silentUpdates.add(f);
|
||||||
|
} else {
|
||||||
|
regularInstalls.add(f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadedFiles.isNotEmpty;
|
// 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<DownloadedApk> moveObtainiumToStart(List<DownloadedApk> items) {
|
||||||
|
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
||||||
|
DownloadedApk? temp;
|
||||||
|
items.removeWhere((element) {
|
||||||
|
bool res = element.appId == obtainiumId;
|
||||||
|
if (res) {
|
||||||
|
temp = element;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
if (temp != null) {
|
||||||
|
items = [temp!, ...items];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
silentUpdates = moveObtainiumToStart(silentUpdates);
|
||||||
|
regularInstalls = moveObtainiumToStart(regularInstalls);
|
||||||
|
|
||||||
|
// // Install silent updates (uncomment when it works - TODO)
|
||||||
|
// for (var u in silentUpdates) {
|
||||||
|
// await installApk(u, silent: true); // Would need to add silent option
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Do regular installs
|
||||||
|
if (regularInstalls.isNotEmpty && context != null) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
await waitForUserToReturnToForeground(context);
|
||||||
|
for (var i in regularInstalls) {
|
||||||
|
try {
|
||||||
|
await installApk(i);
|
||||||
|
} catch (e) {
|
||||||
|
errors.add(i.appId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.content.isNotEmpty) {
|
||||||
|
throw errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationsProvider().cancel(UpdateNotification([]).id);
|
||||||
|
|
||||||
|
return downloadedFiles.map((e) => e!.appId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> getAppsDir() async {
|
Future<Directory> getAppsDir() async {
|
||||||
@ -143,16 +349,62 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return appsDir;
|
return appsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteSavedAPKs() async {
|
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||||
(await getExternalStorageDirectory())
|
if (packageName != null) {
|
||||||
?.listSync()
|
try {
|
||||||
.where((element) => element.path.endsWith('.apk'))
|
return await InstalledApps.getAppInfo(packageName);
|
||||||
.forEach((element) {
|
} catch (e) {
|
||||||
element.deleteSync();
|
// 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 {
|
Future<void> loadApps() async {
|
||||||
|
while (loadingApps) {
|
||||||
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
|
}
|
||||||
loadingApps = true;
|
loadingApps = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||||
@ -160,76 +412,146 @@ class AppsProvider with ChangeNotifier {
|
|||||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||||
.toList();
|
.toList();
|
||||||
apps.clear();
|
apps.clear();
|
||||||
|
var sp = SourceProvider();
|
||||||
|
List<List<String>> errors = [];
|
||||||
for (int i = 0; i < appFiles.length; i++) {
|
for (int i = 0; i < appFiles.length; i++) {
|
||||||
App app =
|
App app =
|
||||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
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;
|
loadingApps = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
List<App> modifiedApps = [];
|
||||||
|
for (var app in apps.values) {
|
||||||
Future<void> saveApp(App app) async {
|
var moddedApp =
|
||||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
getCorrectedInstallStatusAppIfPossible(app.app, app.installedInfo);
|
||||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
if (moddedApp != null) {
|
||||||
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
|
modifiedApps.add(moddedApp);
|
||||||
ifAbsent: () => AppInMemory(app, null));
|
}
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeApp(String appId) async {
|
|
||||||
File file = File('${(await getAppsDir()).path}/$appId.json');
|
|
||||||
if (file.existsSync()) {
|
|
||||||
file.deleteSync();
|
|
||||||
}
|
}
|
||||||
if (apps.containsKey(appId)) {
|
if (modifiedApps.isNotEmpty) {
|
||||||
apps.remove(appId);
|
await saveApps(modifiedApps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, info),
|
||||||
|
ifAbsent: () => AppInMemory(app, null, info));
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool checkAppObjectForUpdate(App app) {
|
Future<void> removeApps(List<String> appIds) async {
|
||||||
if (!apps.containsKey(app.id)) {
|
for (var appId in appIds) {
|
||||||
throw 'App not found';
|
File file = File('${(await getAppsDir()).path}/$appId.json');
|
||||||
|
if (file.existsSync()) {
|
||||||
|
file.deleteSync();
|
||||||
|
}
|
||||||
|
if (apps.containsKey(appId)) {
|
||||||
|
apps.remove(appId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appIds.isNotEmpty) {
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
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;
|
App? currentApp = apps[appId]!.app;
|
||||||
App newApp = await SourceProvider().getApp(currentApp.url);
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
App newApp = await sourceProvider.getApp(
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
sourceProvider.getSource(currentApp.url),
|
||||||
await saveApp(newApp);
|
currentApp.url,
|
||||||
return newApp;
|
currentApp.additionalData,
|
||||||
|
name: currentApp.name,
|
||||||
|
id: currentApp.id);
|
||||||
|
newApp.installedVersion = currentApp.installedVersion;
|
||||||
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
|
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||||
}
|
}
|
||||||
return null;
|
await saveApps([newApp]);
|
||||||
|
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<App>> checkUpdates() async {
|
Future<List<App>> checkUpdates(
|
||||||
|
{DateTime? ignoreAppsCheckedAfter,
|
||||||
|
bool throwErrorsForRetry = false}) async {
|
||||||
List<App> updates = [];
|
List<App> updates = [];
|
||||||
|
MultiAppMultiError errors = MultiAppMultiError();
|
||||||
if (!gettingUpdates) {
|
if (!gettingUpdates) {
|
||||||
gettingUpdates = true;
|
gettingUpdates = true;
|
||||||
|
try {
|
||||||
List<String> appIds = apps.keys.toList();
|
List<String> appIds = apps.values
|
||||||
for (int i = 0; i < appIds.length; i++) {
|
.where((app) =>
|
||||||
App? newApp = await getUpdate(appIds[i]);
|
app.app.lastUpdateCheck == null ||
|
||||||
if (newApp != null) {
|
ignoreAppsCheckedAfter == null ||
|
||||||
updates.add(newApp);
|
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;
|
||||||
|
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;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getExistingUpdates() {
|
List<String> findExistingUpdates(
|
||||||
|
{bool installedOnly = false, bool nonInstalledOnly = false}) {
|
||||||
List<String> updateAppIds = [];
|
List<String> updateAppIds = [];
|
||||||
List<String> appIds = apps.keys.toList();
|
List<String> appIds = apps.keys.toList();
|
||||||
for (int i = 0; i < appIds.length; i++) {
|
for (int i = 0; i < appIds.length; i++) {
|
||||||
App? app = apps[appIds[i]]!.app;
|
App? app = apps[appIds[i]]!.app;
|
||||||
if (app.installedVersion != app.latestVersion) {
|
if (app.installedVersion != app.latestVersion &&
|
||||||
updateAppIds.add(app.id);
|
(!installedOnly || !nonInstalledOnly)) {
|
||||||
|
if ((app.installedVersion == null &&
|
||||||
|
(nonInstalledOnly || !installedOnly) ||
|
||||||
|
(app.installedVersion != null &&
|
||||||
|
(installedOnly || !nonInstalledOnly)))) {
|
||||||
|
updateAppIds.add(app.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updateAppIds;
|
return updateAppIds;
|
||||||
@ -250,31 +572,35 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> importApps(String appsJSON) async {
|
Future<int> importApps(String appsJSON) async {
|
||||||
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
|
|
||||||
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
||||||
.map((e) => App.fromJson(e))
|
.map((e) => App.fromJson(e))
|
||||||
.toList();
|
.toList();
|
||||||
for (App a in importedApps) {
|
while (loadingApps) {
|
||||||
a.installedVersion =
|
await Future.delayed(const Duration(microseconds: 1));
|
||||||
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
|
|
||||||
await saveApp(a);
|
|
||||||
}
|
}
|
||||||
|
for (App a in importedApps) {
|
||||||
|
if (apps[a.id]?.app.installedVersion != null) {
|
||||||
|
a.installedVersion = apps[a.id]?.app.installedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveApps(importedApps);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return importedApps.length;
|
return importedApps.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
foregroundSubscription.cancel();
|
foregroundSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class APKPicker extends StatefulWidget {
|
class APKPicker extends StatefulWidget {
|
||||||
const APKPicker({super.key, required this.app, this.initVal});
|
const APKPicker({super.key, required this.app, this.initVal, this.archs});
|
||||||
|
|
||||||
final App app;
|
final App app;
|
||||||
final String? initVal;
|
final String? initVal;
|
||||||
|
final List<String>? archs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<APKPicker> createState() => _APKPickerState();
|
State<APKPicker> createState() => _APKPickerState();
|
||||||
@ -290,28 +616,41 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Pick an APK'),
|
title: const Text('Pick an APK'),
|
||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
Text('${widget.app.name} has more than one package - pick one.'),
|
Text('${widget.app.name} has more than one package:'),
|
||||||
...widget.app.apkUrls.map((u) => ListTile(
|
const SizedBox(height: 16),
|
||||||
title: Text(Uri.parse(u).pathSegments.last),
|
...widget.app.apkUrls.map(
|
||||||
leading: Radio<String>(
|
(u) => RadioListTile<String>(
|
||||||
value: u,
|
title: Text(Uri.parse(u)
|
||||||
groupValue: apkUrl,
|
.pathSegments
|
||||||
onChanged: (String? val) {
|
.where((element) => element.isNotEmpty)
|
||||||
setState(() {
|
.last),
|
||||||
apkUrl = val;
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop(null);
|
Navigator.of(context).pop(null);
|
||||||
},
|
},
|
||||||
child: const Text('Cancel')),
|
child: const Text('Cancel')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.selectionClick();
|
||||||
Navigator.of(context).pop(apkUrl);
|
Navigator.of(context).pop(apkUrl);
|
||||||
},
|
},
|
||||||
child: const Text('Continue'))
|
child: const Text('Continue'))
|
||||||
@ -319,3 +658,39 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class APKOriginWarningDialog extends StatefulWidget {
|
||||||
|
const APKOriginWarningDialog(
|
||||||
|
{super.key, required this.sourceUrl, required this.apkUrl});
|
||||||
|
|
||||||
|
final String sourceUrl;
|
||||||
|
final String apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Warning'),
|
||||||
|
content: Text(
|
||||||
|
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: const Text('Continue'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -33,6 +33,22 @@ class UpdateNotification extends ObtainiumNotification {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SilentUpdateNotification extends ObtainiumNotification {
|
||||||
|
SilentUpdateNotification(List<App> updates)
|
||||||
|
: super(
|
||||||
|
3,
|
||||||
|
'Apps Updated',
|
||||||
|
'',
|
||||||
|
'APPS_UPDATED',
|
||||||
|
'Apps Updated',
|
||||||
|
'Notifies the user that updates to one or more Apps were applied in the background',
|
||||||
|
Importance.defaultImportance) {
|
||||||
|
message = updates.length == 1
|
||||||
|
? '${updates[0].name} was updated to ${updates[0].latestVersion}.'
|
||||||
|
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
||||||
ErrorCheckingUpdatesNotification(String error)
|
ErrorCheckingUpdatesNotification(String error)
|
||||||
: super(
|
: super(
|
||||||
@ -45,6 +61,24 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
|||||||
Importance.high);
|
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(
|
final completeInstallationNotification = ObtainiumNotification(
|
||||||
1,
|
1,
|
||||||
'Complete App Installation',
|
'Complete App Installation',
|
||||||
|
@ -9,6 +9,20 @@ enum ThemeSettings { system, light, dark }
|
|||||||
|
|
||||||
enum ColourSettings { basic, materialYou }
|
enum ColourSettings { basic, materialYou }
|
||||||
|
|
||||||
|
enum SortColumnSettings { added, nameAuthor, authorName }
|
||||||
|
|
||||||
|
enum SortOrderSettings { ascending, descending }
|
||||||
|
|
||||||
|
const maxAPIRateLimitMinutes = 30;
|
||||||
|
const minUpdateIntervalMinutes = maxAPIRateLimitMinutes + 30;
|
||||||
|
const maxUpdateIntervalMinutes = 4320;
|
||||||
|
List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
|
||||||
|
.where((element) =>
|
||||||
|
(element >= minUpdateIntervalMinutes &&
|
||||||
|
element <= maxUpdateIntervalMinutes) ||
|
||||||
|
element == 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
class SettingsProvider with ChangeNotifier {
|
class SettingsProvider with ChangeNotifier {
|
||||||
SharedPreferences? prefs;
|
SharedPreferences? prefs;
|
||||||
|
|
||||||
@ -41,11 +55,41 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int get updateInterval {
|
int get updateInterval {
|
||||||
return prefs?.getInt('updateInterval') ?? 1440;
|
var min = prefs?.getInt('updateInterval') ?? 360;
|
||||||
|
if (!updateIntervals.contains(min)) {
|
||||||
|
var temp = updateIntervals[0];
|
||||||
|
for (var i in updateIntervals) {
|
||||||
|
if (min > i && i != 0) {
|
||||||
|
temp = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
min = temp;
|
||||||
|
}
|
||||||
|
return min;
|
||||||
}
|
}
|
||||||
|
|
||||||
set updateInterval(int min) {
|
set updateInterval(int min) {
|
||||||
prefs?.setInt('updateInterval', min < 15 ? 15 : min);
|
prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
SortColumnSettings get sortColumn {
|
||||||
|
return SortColumnSettings.values[
|
||||||
|
prefs?.getInt('sortColumn') ?? SortColumnSettings.nameAuthor.index];
|
||||||
|
}
|
||||||
|
|
||||||
|
set sortColumn(SortColumnSettings s) {
|
||||||
|
prefs?.setInt('sortColumn', s.index);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
SortOrderSettings get sortOrder {
|
||||||
|
return SortOrderSettings.values[
|
||||||
|
prefs?.getInt('sortOrder') ?? SortOrderSettings.ascending.index];
|
||||||
|
}
|
||||||
|
|
||||||
|
set sortOrder(SortOrderSettings s) {
|
||||||
|
prefs?.setInt('sortOrder', s.index);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,4 +113,31 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get showAppWebpage {
|
||||||
|
return prefs?.getBool('showAppWebpage') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set showAppWebpage(bool show) {
|
||||||
|
prefs?.setBool('showAppWebpage', show);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSettingString(String settingId, String value) {
|
||||||
|
prefs?.setString(settingId, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,16 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:html/dom.dart';
|
import 'package:html/dom.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:obtainium/app_sources/fdroid.dart';
|
||||||
import 'package:html/parser.dart';
|
import 'package:obtainium/app_sources/github.dart';
|
||||||
|
import 'package:obtainium/app_sources/gitlab.dart';
|
||||||
|
import 'package:obtainium/app_sources/izzyondroid.dart';
|
||||||
|
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 {
|
class AppNames {
|
||||||
late String author;
|
late String author;
|
||||||
@ -29,12 +37,24 @@ class App {
|
|||||||
String? installedVersion;
|
String? installedVersion;
|
||||||
late String latestVersion;
|
late String latestVersion;
|
||||||
List<String> apkUrls = [];
|
List<String> apkUrls = [];
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
late int preferredApkIndex;
|
||||||
this.latestVersion, this.apkUrls);
|
late List<String> additionalData;
|
||||||
|
late DateTime? lastUpdateCheck;
|
||||||
|
App(
|
||||||
|
this.id,
|
||||||
|
this.url,
|
||||||
|
this.author,
|
||||||
|
this.name,
|
||||||
|
this.installedVersion,
|
||||||
|
this.latestVersion,
|
||||||
|
this.apkUrls,
|
||||||
|
this.preferredApkIndex,
|
||||||
|
this.additionalData,
|
||||||
|
this.lastUpdateCheck);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
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()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||||
@ -46,7 +66,16 @@ class App {
|
|||||||
? null
|
? null
|
||||||
: json['installedVersion'] as String,
|
: json['installedVersion'] as String,
|
||||||
json['latestVersion'] as String,
|
json['latestVersion'] as String,
|
||||||
List<String>.from(jsonDecode(json['apkUrls'])));
|
json['apkUrls'] == null
|
||||||
|
? []
|
||||||
|
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
|
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||||
|
json['additionalData'] == null
|
||||||
|
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
||||||
|
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||||
|
json['lastUpdateCheck'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']));
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -56,15 +85,38 @@ class App {
|
|||||||
'installedVersion': installedVersion,
|
'installedVersion': installedVersion,
|
||||||
'latestVersion': latestVersion,
|
'latestVersion': latestVersion,
|
||||||
'apkUrls': jsonEncode(apkUrls),
|
'apkUrls': jsonEncode(apkUrls),
|
||||||
|
'preferredApkIndex': preferredApkIndex,
|
||||||
|
'additionalData': jsonEncode(additionalData),
|
||||||
|
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
escapeRegEx(String s) {
|
// Ensure the input is starts with HTTPS and has no WWW
|
||||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
preStandardizeUrl(String url) {
|
||||||
return "\\${x[0]}";
|
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||||
});
|
url.toLowerCase().indexOf('https://') != 0) {
|
||||||
|
url = 'https://$url';
|
||||||
|
}
|
||||||
|
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
||||||
|
url = 'https://${url.substring(12)}';
|
||||||
|
}
|
||||||
|
url = url
|
||||||
|
.split('/')
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.join('/')
|
||||||
|
.replaceFirst(':/', '://');
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const String couldNotFindReleases = 'Could not find a suitable release';
|
||||||
|
const String couldNotFindLatestVersion =
|
||||||
|
'Could not determine latest release version';
|
||||||
|
String notValidURL(String sourceName) {
|
||||||
|
return 'Not a valid $sourceName App URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
const String noAPKFound = 'No APK found';
|
||||||
|
|
||||||
List<String> getLinksFromParsedHTML(
|
List<String> getLinksFromParsedHTML(
|
||||||
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
||||||
dom
|
dom
|
||||||
@ -79,160 +131,39 @@ List<String> getLinksFromParsedHTML(
|
|||||||
abstract class AppSource {
|
abstract class AppSource {
|
||||||
late String host;
|
late String host;
|
||||||
String standardizeURL(String url);
|
String standardizeURL(String url);
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData);
|
||||||
AppNames getAppNames(String standardUrl);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
class GitHub implements AppSource {
|
abstract class MassAppUrlSource {
|
||||||
@override
|
late String name;
|
||||||
late String host = 'github.com';
|
late List<String> requiredArgs;
|
||||||
|
Future<List<String>> getUrls(List<String> args);
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]*/[^/]*');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
// The GitHub RSS feed does not contain asset download details, so we use web scraping (avoid API due to rate limits)
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var standardUri = Uri.parse(standardUrl);
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var apkUrlList = getLinksFromParsedHTML(
|
|
||||||
parsedHtml,
|
|
||||||
RegExp(
|
|
||||||
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
|
|
||||||
caseSensitive: false),
|
|
||||||
standardUri.origin);
|
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw 'No APK found';
|
|
||||||
}
|
|
||||||
String getTag(String url) {
|
|
||||||
List<String> parts = url.split('/');
|
|
||||||
return parts[parts.length - 2];
|
|
||||||
}
|
|
||||||
|
|
||||||
String latestTag = getTag(apkUrlList[0]);
|
|
||||||
String? version = parsedHtml
|
|
||||||
.querySelector('.octicon-tag')
|
|
||||||
?.nextElementSibling
|
|
||||||
?.innerHtml
|
|
||||||
.trim();
|
|
||||||
if (version == null) {
|
|
||||||
throw 'Could not determine latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(version,
|
|
||||||
apkUrlList.where((element) => getTag(element) == latestTag).toList());
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
|
||||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
|
||||||
return AppNames(names[0], names[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class GitLab implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'gitlab.com';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]*/[^/]*');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
// GitLab provides an RSS feed with all the details we need
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var standardUri = Uri.parse(standardUrl);
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var entry = parsedHtml.querySelector('entry');
|
|
||||||
var entryContent =
|
|
||||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
|
||||||
var apkUrlList = getLinksFromParsedHTML(
|
|
||||||
entryContent,
|
|
||||||
RegExp(
|
|
||||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
|
||||||
caseSensitive: false),
|
|
||||||
standardUri.origin);
|
|
||||||
if (apkUrlList.isEmpty) {
|
|
||||||
throw 'No APK found';
|
|
||||||
}
|
|
||||||
|
|
||||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
|
||||||
var version =
|
|
||||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
|
||||||
if (version == null) {
|
|
||||||
throw 'Could not determine latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(version, apkUrlList);
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
// Same as GitHub
|
|
||||||
return GitHub().getAppNames(standardUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Signal implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'signal.org';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
return 'https://$host';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res =
|
|
||||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var json = jsonDecode(res.body);
|
|
||||||
String? apkUrl = json['url'];
|
|
||||||
if (apkUrl == null) {
|
|
||||||
throw 'No APK found';
|
|
||||||
}
|
|
||||||
String? version = json['versionName'];
|
|
||||||
if (version == null) {
|
|
||||||
throw 'Could not determine latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(version, [apkUrl]);
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) => AppNames('signal', 'signal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SourceProvider {
|
class SourceProvider {
|
||||||
List<AppSource> sources = [GitHub(), GitLab(), Signal()];
|
|
||||||
|
|
||||||
// Add more source classes here so they are available via the service
|
// Add more source classes here so they are available via the service
|
||||||
|
List<AppSource> sources = [
|
||||||
|
GitHub(),
|
||||||
|
GitLab(),
|
||||||
|
FDroid(),
|
||||||
|
IzzyOnDroid(),
|
||||||
|
Mullvad(),
|
||||||
|
Signal(),
|
||||||
|
SourceForge()
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add more mass url source classes here so they are available via the service
|
||||||
|
List<MassAppUrlSource> massUrlSources = [GitHubStars()];
|
||||||
|
|
||||||
AppSource getSource(String url) {
|
AppSource getSource(String url) {
|
||||||
|
url = preStandardizeUrl(url);
|
||||||
AppSource? source;
|
AppSource? source;
|
||||||
for (var s in sources) {
|
for (var s in sources) {
|
||||||
if (url.toLowerCase().contains('://${s.host}')) {
|
if (url.toLowerCase().contains('://${s.host}')) {
|
||||||
@ -241,31 +172,60 @@ class SourceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
throw 'URL does not match a known source';
|
throw UnsupportedURLError();
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
for (var row in source.additionalDataFormItems) {
|
||||||
url.toLowerCase().indexOf('https://') != 0) {
|
for (var element in row) {
|
||||||
url = 'https://$url';
|
if (element.required) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
return false;
|
||||||
url = 'https://${url.substring(12)}';
|
}
|
||||||
}
|
|
||||||
AppSource source = getSource(url);
|
String generateTempID(AppNames names, AppSource source) =>
|
||||||
String standardUrl = source.standardizeURL(url);
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}';
|
||||||
|
|
||||||
|
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||||
|
{String name = '', String? id}) async {
|
||||||
|
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||||
AppNames names = source.getAppNames(standardUrl);
|
AppNames names = source.getAppNames(standardUrl);
|
||||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
APKDetails apk =
|
||||||
|
await source.getLatestAPKDetails(standardUrl, additionalData);
|
||||||
return App(
|
return App(
|
||||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
id ?? generateTempID(names, source),
|
||||||
standardUrl,
|
standardUrl,
|
||||||
names.author[0].toUpperCase() + names.author.substring(1),
|
names.author[0].toUpperCase() + names.author.substring(1),
|
||||||
names.name[0].toUpperCase() + names.name.substring(1),
|
name.trim().isNotEmpty
|
||||||
|
? name
|
||||||
|
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||||
null,
|
null,
|
||||||
apk.version,
|
apk.version.replaceAll('/', '-'),
|
||||||
apk.apkUrls);
|
apk.apkUrls,
|
||||||
|
apk.apkUrls.length - 1,
|
||||||
|
additionalData,
|
||||||
|
DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns errors in [results, errors] instead of throwing them
|
||||||
|
Future<List<dynamic>> getApps(List<String> urls,
|
||||||
|
{List<String> ignoreUrls = const []}) async {
|
||||||
|
List<App> apps = [];
|
||||||
|
Map<String, dynamic> errors = {};
|
||||||
|
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
||||||
|
try {
|
||||||
|
var source = getSource(url);
|
||||||
|
apps.add(await getApp(source, url, source.additionalDataDefaults));
|
||||||
|
} catch (e) {
|
||||||
|
errors.addAll(<String, dynamic>{url: e});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [apps, errors];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||||
|
177
pubspec.lock
@ -1,13 +1,27 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
android_alarm_manager_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: android_alarm_manager_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
|
animations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: animations
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.7"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.1"
|
version: "3.3.2"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -64,6 +78,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.16.0"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.3+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -92,13 +113,27 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.8"
|
version: "0.7.8"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
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: "7.0.0"
|
||||||
dynamic_color:
|
dynamic_color:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dynamic_color
|
name: dynamic_color
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.3"
|
version: "1.5.4"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -120,6 +155,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.4"
|
version: "6.1.4"
|
||||||
|
file_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_picker
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "5.2.2"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -152,21 +194,28 @@ packages:
|
|||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.8.0+1"
|
version: "12.0.3"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "2.0.0"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.7"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -183,14 +232,14 @@ packages:
|
|||||||
name: fluttertoast
|
name: fluttertoast
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.9"
|
version: "8.1.1"
|
||||||
html:
|
html:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: html
|
name: html
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.0"
|
version: "0.15.1"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -204,14 +253,14 @@ packages:
|
|||||||
name: http_parser
|
name: http_parser
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.2"
|
||||||
install_plugin_v2:
|
install_plugin_v2:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -219,6 +268,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -232,14 +288,14 @@ packages:
|
|||||||
name: json_annotation
|
name: json_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.6.0"
|
version: "4.7.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: lints
|
name: lints
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.1"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -253,7 +309,7 @@ packages:
|
|||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.1.5"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -261,6 +317,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.8.0"
|
version: "1.8.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -268,6 +331,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -288,7 +365,7 @@ packages:
|
|||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.20"
|
version: "2.0.21"
|
||||||
path_provider_ios:
|
path_provider_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -316,56 +393,56 @@ packages:
|
|||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.5"
|
||||||
path_provider_windows:
|
path_provider_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_windows
|
name: path_provider_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
permission_handler:
|
permission_handler:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "10.2.0"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.0"
|
version: "10.2.0"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_apple
|
name: permission_handler_apple
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.4"
|
version: "9.0.7"
|
||||||
permission_handler_platform_interface:
|
permission_handler_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_platform_interface
|
name: permission_handler_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.7.0"
|
version: "3.9.0"
|
||||||
permission_handler_windows:
|
permission_handler_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_windows
|
name: permission_handler_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.0"
|
version: "0.1.2"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0"
|
version: "5.1.0"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -379,7 +456,7 @@ packages:
|
|||||||
name: plugin_platform_interface
|
name: plugin_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
process:
|
process:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -393,7 +470,21 @@ packages:
|
|||||||
name: provider
|
name: provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
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: "6.2.0"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -407,7 +498,7 @@ packages:
|
|||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.12"
|
version: "2.0.14"
|
||||||
shared_preferences_ios:
|
shared_preferences_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -435,7 +526,7 @@ packages:
|
|||||||
name: shared_preferences_platform_interface
|
name: shared_preferences_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.1.0"
|
||||||
shared_preferences_web:
|
shared_preferences_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -461,7 +552,7 @@ packages:
|
|||||||
name: source_span
|
name: source_span
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -503,7 +594,7 @@ packages:
|
|||||||
name: timezone
|
name: timezone
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.0"
|
version: "0.9.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -517,14 +608,14 @@ packages:
|
|||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5"
|
version: "6.1.6"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.17"
|
version: "6.0.21"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -552,7 +643,7 @@ packages:
|
|||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.1"
|
||||||
url_launcher_web:
|
url_launcher_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -567,6 +658,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.1"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.6"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -587,35 +685,28 @@ packages:
|
|||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.5"
|
version: "2.10.4"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_platform_interface
|
name: webview_flutter_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.2"
|
version: "1.9.5"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.3"
|
version: "2.9.5"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "3.0.1"
|
||||||
workmanager:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: workmanager
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "0.5.0"
|
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -638,5 +729,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
dart: ">=2.18.2 <3.0.0"
|
||||||
flutter: ">=3.1.0-0.0.pre.1036"
|
flutter: ">=3.3.0"
|
||||||
|
26
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
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.1.5+6 # When changing this, update the tag in main() accordingly
|
version: 0.6.9+53 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
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.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
@ -35,21 +35,27 @@ dependencies:
|
|||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.5
|
||||||
path_provider: ^2.0.11
|
path_provider: ^2.0.11
|
||||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||||
flutter_local_notifications: ^9.8.0+1
|
flutter_local_notifications: ^12.0.0
|
||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
webview_flutter: ^3.0.4
|
webview_flutter: ^3.0.4
|
||||||
workmanager: ^0.5.0
|
dynamic_color: ^1.5.4
|
||||||
dynamic_color: ^1.5.3
|
|
||||||
install_plugin_v2: ^1.0.0 # Try replacing this
|
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
shared_preferences: ^2.0.15
|
shared_preferences: ^2.0.15
|
||||||
url_launcher: ^6.1.5
|
url_launcher: ^6.1.5
|
||||||
permission_handler: ^10.0.0
|
permission_handler: ^10.0.0
|
||||||
fluttertoast: ^8.0.9
|
fluttertoast: ^8.0.9
|
||||||
|
device_info_plus: ^8.0.0
|
||||||
|
file_picker: ^5.1.0
|
||||||
|
animations: ^2.0.4
|
||||||
|
install_plugin_v2: ^1.0.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:
|
dev_dependencies:
|
||||||
@ -62,13 +68,13 @@ dev_dependencies:
|
|||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^2.0.0
|
flutter_lints: ^2.0.1
|
||||||
|
|
||||||
flutter_icons:
|
flutter_icons:
|
||||||
android: true
|
android: true
|
||||||
image_path: "assets/icon.png"
|
image_path: "assets/graphics/icon.png"
|
||||||
adaptive_icon_background: "#FFFFFF"
|
adaptive_icon_background: "#FFFFFF"
|
||||||
adaptive_icon_foreground: "assets/icon.png"
|
adaptive_icon_foreground: "assets/graphics/icon.png"
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
Before Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 192 KiB |
@ -13,7 +13,7 @@ import 'package:obtainium/main.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(const Obtainium());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
||||||
|