Compare commits
95 Commits
v0.1.5-bet
...
v0.6.0-bet
Author | SHA1 | Date | |
---|---|---|---|
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" /> |
|
||||||
|
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 |
112
lib/app_sources/apkmirror.dart
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class APKMirror implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'apkmirror.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||||
|
'$standardUrl#whatsnew';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||||
|
var originalUri = Uri.parse(apkUrl);
|
||||||
|
var res = await get(originalUri);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw false;
|
||||||
|
}
|
||||||
|
var href =
|
||||||
|
parse(res.body).querySelector('.downloadButton')?.attributes['href'];
|
||||||
|
if (href == null) {
|
||||||
|
throw false;
|
||||||
|
}
|
||||||
|
var res2 = await get(Uri.parse('${originalUri.origin}$href'), headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
||||||
|
});
|
||||||
|
if (res2.statusCode != 200) {
|
||||||
|
throw false;
|
||||||
|
}
|
||||||
|
var links = parse(res2.body)
|
||||||
|
.querySelectorAll('a')
|
||||||
|
.where((element) => element.innerHtml == 'here')
|
||||||
|
.map((e) => e.attributes['href'])
|
||||||
|
.where((element) => element != null)
|
||||||
|
.toList();
|
||||||
|
if (links.isEmpty) {
|
||||||
|
throw false;
|
||||||
|
}
|
||||||
|
return '${originalUri.origin}${links[0]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(
|
||||||
|
String standardUrl, List<String> additionalData) async {
|
||||||
|
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
var nextUrl = parse(res.body)
|
||||||
|
.querySelector('item')
|
||||||
|
?.querySelector('link')
|
||||||
|
?.nextElementSibling
|
||||||
|
?.innerHtml;
|
||||||
|
if (nextUrl == null) {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
Response res2 = await get(Uri.parse(nextUrl), headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64; rv:105.0) Gecko/20100101 Firefox/105.0'
|
||||||
|
});
|
||||||
|
if (res2.statusCode != 200) {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
var html2 = parse(res2.body);
|
||||||
|
var origin = Uri.parse(standardUrl).origin;
|
||||||
|
List<String> apkUrls = html2
|
||||||
|
.querySelectorAll('.apkm-badge')
|
||||||
|
.map((e) => e.innerHtml != 'APK'
|
||||||
|
? ''
|
||||||
|
: e.previousElementSibling?.attributes['href'] ?? '')
|
||||||
|
.where((element) => element.isNotEmpty)
|
||||||
|
.map((e) => '$origin$e')
|
||||||
|
.toList();
|
||||||
|
if (apkUrls.isEmpty) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
var version = html2.querySelector('span.active.accent_color')?.innerHtml;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, apkUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||||
|
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||||
|
return AppNames(names[1], names[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
72
lib/app_sources/fdroid.dart
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class 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 notValidURL(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 latestReleaseDiv =
|
||||||
|
parse(res.body).querySelector('#latest.package-version');
|
||||||
|
var apkUrl = latestReleaseDiv
|
||||||
|
?.querySelector('.package-version-download a')
|
||||||
|
?.attributes['href'];
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
var version = latestReleaseDiv
|
||||||
|
?.querySelector('.package-version-header b')
|
||||||
|
?.innerHtml
|
||||||
|
.split(' ')
|
||||||
|
.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, [apkUrl]);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = [];
|
||||||
|
}
|
184
lib/app_sources/github.dart
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
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 notValidURL(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 couldNotFindReleases;
|
||||||
|
}
|
||||||
|
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||||
|
throw noAPKFound;
|
||||||
|
}
|
||||||
|
String? version = targetRelease['tag_name'];
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
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 couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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),
|
||||||
|
))
|
||||||
|
])
|
||||||
|
];
|
||||||
|
}
|
81
lib/app_sources/gitlab.dart
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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/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 notValidURL(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(
|
||||||
|
'^${escapeRegEx(standardUri.path)}/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 noAPKFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||||
|
var version =
|
||||||
|
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, apkUrlList);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = [];
|
||||||
|
}
|
74
lib/app_sources/izzyondroid.dart
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class 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 notValidURL(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 noAPKFound;
|
||||||
|
}
|
||||||
|
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 couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = [];
|
||||||
|
}
|
61
lib/app_sources/mullvad.dart
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class 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 notValidURL(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 couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(
|
||||||
|
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) {
|
||||||
|
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
53
lib/app_sources/signal.dart
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.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 noAPKFound;
|
||||||
|
}
|
||||||
|
String? version = json['versionName'];
|
||||||
|
if (version == null) {
|
||||||
|
throw couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
return APKDetails(version, [apkUrl]);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> additionalDataDefaults = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||||
|
}
|
77
lib/app_sources/sourceforge.dart
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import 'package:html/parser.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class 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 notValidURL(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 couldNotFindLatestVersion;
|
||||||
|
}
|
||||||
|
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 noAPKFound;
|
||||||
|
}
|
||||||
|
return APKDetails(version, apkUrlList);
|
||||||
|
} else {
|
||||||
|
throw couldNotFindReleases;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)],
|
||||||
|
))
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
75
lib/components/generated_form_modal.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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();
|
||||||
|
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'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
8
lib/custom_errors.dart
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
class RateLimitError {
|
||||||
|
late int remainingMinutes;
|
||||||
|
RateLimitError(this.remainingMinutes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'Too many requests (rate limited) - try again in $remainingMinutes minutes';
|
||||||
|
}
|
184
lib/main.dart
@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
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';
|
||||||
@ -9,61 +12,128 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:workmanager/workmanager.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';
|
||||||
|
|
||||||
|
const String currentVersion = '0.6.0';
|
||||||
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 String bgUpdateCheckTaskName = 'bg-update-check';
|
||||||
|
|
||||||
|
bgUpdateCheck(int? ignoreAfterMicroseconds) async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
DateTime? ignoreAfter = ignoreAfterMicroseconds != null
|
||||||
|
? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds)
|
||||||
|
: null;
|
||||||
|
var notificationsProvider = NotificationsProvider();
|
||||||
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
|
try {
|
||||||
|
var appsProvider = AppsProvider();
|
||||||
|
await notificationsProvider.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
|
await appsProvider.loadApps(shouldCorrectInstallStatus: false);
|
||||||
|
List<String> existingUpdateIds =
|
||||||
|
appsProvider.getExistingUpdates(installedOnly: true);
|
||||||
|
DateTime nextIgnoreAfter = DateTime.now();
|
||||||
|
String? err;
|
||||||
|
try {
|
||||||
|
await appsProvider.checkUpdates(
|
||||||
|
ignoreAfter: ignoreAfter,
|
||||||
|
immediatelyThrowRateLimitError: true,
|
||||||
|
immediatelyThrowSocketError: true,
|
||||||
|
shouldCorrectInstallStatus: false);
|
||||||
|
} catch (e) {
|
||||||
|
if (e is RateLimitError || e is SocketException) {
|
||||||
|
String nextTaskName =
|
||||||
|
'$bgUpdateCheckTaskName-${nextIgnoreAfter.microsecondsSinceEpoch.toString()}';
|
||||||
|
Workmanager().registerOneOffTask(nextTaskName, nextTaskName,
|
||||||
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
|
initialDelay: Duration(
|
||||||
|
minutes: e is RateLimitError ? e.remainingMinutes : 15),
|
||||||
|
inputData: {'ignoreAfter': nextIgnoreAfter.microsecondsSinceEpoch});
|
||||||
|
} else {
|
||||||
|
err = e.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<App> newUpdates = appsProvider
|
||||||
|
.getExistingUpdates(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;
|
||||||
|
}
|
||||||
|
return Future.value(true);
|
||||||
|
} catch (e) {
|
||||||
|
notificationsProvider
|
||||||
|
.notify(ErrorCheckingUpdatesNotification(e.toString()));
|
||||||
|
return Future.error(false);
|
||||||
|
} finally {
|
||||||
|
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
void bgTaskCallback() {
|
||||||
// Background update checking process
|
// Background process callback
|
||||||
Workmanager().executeTask((task, taskName) async {
|
Workmanager().executeTask((task, inputData) async {
|
||||||
var appsProvider = AppsProvider(bg: true);
|
return await bgUpdateCheck(inputData?['ignoreAfter']);
|
||||||
var notificationsProvider = NotificationsProvider();
|
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
|
||||||
try {
|
|
||||||
await notificationsProvider
|
|
||||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
|
||||||
await appsProvider.loadApps();
|
|
||||||
List<App> updates = await appsProvider.checkUpdates();
|
|
||||||
if (updates.isNotEmpty) {
|
|
||||||
notificationsProvider.notify(UpdateNotification(updates),
|
|
||||||
cancelExisting: true);
|
|
||||||
}
|
|
||||||
return Future.value(true);
|
|
||||||
} catch (e) {
|
|
||||||
notificationsProvider.notify(
|
|
||||||
ErrorCheckingUpdatesNotification(e.toString()),
|
|
||||||
cancelExisting: true);
|
|
||||||
return Future.value(false);
|
|
||||||
} 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);
|
);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
}
|
||||||
Workmanager().initialize(
|
Workmanager().initialize(
|
||||||
bgTaskCallback,
|
bgTaskCallback,
|
||||||
);
|
);
|
||||||
runApp(MultiProvider(
|
runApp(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => AppsProvider(
|
||||||
|
shouldLoadApps: true,
|
||||||
|
shouldCheckUpdatesAfterLoad: false,
|
||||||
|
shouldDeleteAPKs: true)),
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider())
|
||||||
],
|
],
|
||||||
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 +141,42 @@ 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) {
|
||||||
|
Workmanager().cancelByUniqueName(bgUpdateCheckTaskName);
|
||||||
|
} else {
|
||||||
|
Workmanager().registerPeriodicTask(
|
||||||
|
bgUpdateCheckTaskName, bgUpdateCheckTaskName,
|
||||||
|
frequency: Duration(minutes: existingUpdateInterval),
|
||||||
|
initialDelay: Duration(minutes: existingUpdateInterval),
|
||||||
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
|
existingWorkPolicy: ExistingWorkPolicy.replace,
|
||||||
|
backoffPolicy: BackoffPolicy.linear,
|
||||||
|
backoffPolicyDelay:
|
||||||
|
const Duration(minutes: minUpdateIntervalMinutes));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 MassAppSource {
|
||||||
|
@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 'Unable to find user\'s starred repos';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getUrls(List<String> args) async {
|
||||||
|
if (args.length != requiredArgs.length) {
|
||||||
|
throw 'Wrong number of arguments provided';
|
||||||
|
}
|
||||||
|
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,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.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 +17,211 @@ 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
|
||||||
|
.doesSourceHaveRequiredAdditionalData(
|
||||||
|
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
|
||||||
|
.selectApkUrl(app, context);
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw 'Cancelled';
|
||||||
|
}
|
||||||
|
app.preferredApkIndex =
|
||||||
|
app.apkUrls.indexOf(apkUrl);
|
||||||
|
var downloadedApk =
|
||||||
|
await appsProvider
|
||||||
|
.downloadApp(app);
|
||||||
|
app.id = downloadedApk.appId;
|
||||||
|
if (appsProvider.apps
|
||||||
|
.containsKey(app.id)) {
|
||||||
|
throw 'App already added';
|
||||||
|
}
|
||||||
|
await appsProvider.saveApps([app]);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}()
|
||||||
|
.then((app) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
AppPage(
|
||||||
|
appId: app.id)));
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
gettingAppInfo = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Add'))
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
if (pickedSource != null)
|
||||||
],
|
Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
),
|
children: [
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
const Divider(
|
||||||
const Text(
|
height: 64,
|
||||||
'Supported Sources:',
|
),
|
||||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
Text(
|
||||||
// style: Theme.of(context).textTheme.bodySmall,
|
'Additional Options for ${pickedSource?.runtimeType}',
|
||||||
),
|
style: TextStyle(
|
||||||
const SizedBox(
|
color:
|
||||||
height: 8,
|
Theme.of(context).colorScheme.primary)),
|
||||||
),
|
const SizedBox(
|
||||||
...sourceProvider
|
height: 16,
|
||||||
.getSourceHosts()
|
),
|
||||||
.map((e) => GestureDetector(
|
if (pickedSource!
|
||||||
onTap: () {
|
.additionalDataFormItems.isNotEmpty)
|
||||||
launchUrlString('https://$e',
|
GeneratedForm(
|
||||||
mode: LaunchMode.externalApplication);
|
items: pickedSource!.additionalDataFormItems,
|
||||||
},
|
onValueChanges: (values, valid) {
|
||||||
child: Text(
|
setState(() {
|
||||||
e,
|
additionalData = values;
|
||||||
style: const TextStyle(
|
validAdditionalData = valid;
|
||||||
decoration: TextDecoration.underline,
|
});
|
||||||
fontStyle: FontStyle.italic),
|
},
|
||||||
)))
|
defaultValues:
|
||||||
.toList()
|
pickedSource!.additionalDataDefaults),
|
||||||
]),
|
if (pickedSource!
|
||||||
if (gettingAppInfo)
|
.additionalDataFormItems.isNotEmpty)
|
||||||
const LinearProgressIndicator()
|
const SizedBox(
|
||||||
else
|
height: 8,
|
||||||
Container(),
|
),
|
||||||
],
|
],
|
||||||
)),
|
)
|
||||||
);
|
else
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// const SizedBox(
|
||||||
|
// height: 48,
|
||||||
|
// ),
|
||||||
|
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,10 @@
|
|||||||
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/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 +18,113 @@ 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.getUpdate(id).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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!,
|
||||||
|
scale: 1.5,
|
||||||
|
)
|
||||||
|
])
|
||||||
|
: Container(),
|
||||||
|
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,6 +136,80 @@ 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 ||
|
||||||
@ -50,12 +220,18 @@ class _AppPageState extends State<AppPage> {
|
|||||||
? () {
|
? () {
|
||||||
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) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString())),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@ -67,21 +243,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 +266,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 +274,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,558 @@
|
|||||||
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/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 = {};
|
||||||
|
|
||||||
|
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 existingUpdateIdsAllOrSelected = appsProvider
|
||||||
|
.getExistingUpdates(installedOnly: true)
|
||||||
|
.where((element) => selectedIds.isEmpty
|
||||||
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
|
: selectedIds.contains(element))
|
||||||
|
.toList();
|
||||||
|
var newInstallIdsAllOrSelected = appsProvider
|
||||||
|
.getExistingUpdates(nonInstalledOnly: true)
|
||||||
|
.where((element) => selectedIds.isEmpty
|
||||||
|
? sortedApps.where((a) => a.app.id == element).isNotEmpty
|
||||||
|
: selectedIds.contains(element))
|
||||||
|
.toList();
|
||||||
|
|
||||||
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
|
return appsProvider.checkUpdates().catchError((e) {
|
||||||
: () {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
HapticFeedback.heavyImpact();
|
SnackBar(content: Text(e.toString())),
|
||||||
context
|
);
|
||||||
.read<SettingsProvider>()
|
});
|
||||||
.getInstallPermission()
|
},
|
||||||
.then((_) {
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
appsProvider.downloadAndInstallLatestApp(
|
const CustomAppBar(title: 'Apps'),
|
||||||
existingUpdateAppIds, context);
|
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||||
});
|
SliverFillRemaining(
|
||||||
},
|
child: Center(
|
||||||
icon: const Icon(Icons.update),
|
child: appsProvider.loadingApps
|
||||||
label: const Text('Update All')),
|
? const CircularProgressIndicator()
|
||||||
body: Center(
|
: Text(
|
||||||
child: appsProvider.loadingApps
|
appsProvider.apps.isEmpty
|
||||||
? const CircularProgressIndicator()
|
? 'No Apps'
|
||||||
: appsProvider.apps.isEmpty
|
: 'No Apps for Filter',
|
||||||
? Text(
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
'No Apps',
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headline4,
|
))),
|
||||||
)
|
SliverList(
|
||||||
: RefreshIndicator(
|
delegate: SliverChildBuilderDelegate(
|
||||||
onRefresh: () {
|
(BuildContext context, int index) {
|
||||||
HapticFeedback.lightImpact();
|
return ListTile(
|
||||||
return appsProvider.checkUpdates();
|
selectedTileColor:
|
||||||
},
|
Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
||||||
child: ListView(
|
selected: selectedIds.contains(sortedApps[index].app.id),
|
||||||
children: appsProvider.apps.values
|
onLongPress: () {
|
||||||
.map(
|
toggleAppSelected(sortedApps[index].app.id);
|
||||||
(e) => ListTile(
|
},
|
||||||
title: Text('${e.app.author}/${e.app.name}'),
|
leading: sortedApps[index].installedInfo != null
|
||||||
subtitle: Text(
|
? Image.memory(sortedApps[index].installedInfo!.icon!)
|
||||||
e.app.installedVersion ?? 'Not Installed'),
|
: null,
|
||||||
trailing: e.downloadProgress != null
|
title: Text(sortedApps[index].installedInfo?.name ??
|
||||||
? Text(
|
sortedApps[index].app.name),
|
||||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||||
: (e.app.installedVersion != null &&
|
trailing: sortedApps[index].downloadProgress != null
|
||||||
e.app.installedVersion !=
|
? Text(
|
||||||
e.app.latestVersion
|
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||||
? const Text('Update Available')
|
: (sortedApps[index].app.installedVersion != null &&
|
||||||
: null),
|
sortedApps[index].app.installedVersion !=
|
||||||
onTap: () {
|
sortedApps[index].app.latestVersion
|
||||||
Navigator.push(
|
? Column(
|
||||||
context,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
MaterialPageRoute(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
builder: (context) =>
|
children: [
|
||||||
AppPage(appId: e.app.id)),
|
Text(appsProvider.areDownloadsRunning()
|
||||||
);
|
? 'Please Wait...'
|
||||||
},
|
: 'Update Available'),
|
||||||
),
|
SourceProvider()
|
||||||
)
|
.getSource(sortedApps[index].app.url)
|
||||||
.toList(),
|
.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),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(sortedApps[index].app.installedVersion ??
|
||||||
|
'Not Installed')),
|
||||||
|
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: const ['true'],
|
||||||
|
initValid: true,
|
||||||
|
);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null) {
|
||||||
|
bool shouldInstallUpdates =
|
||||||
|
values.length < 2 || values[0] == 'true';
|
||||||
|
bool shouldInstallNew =
|
||||||
|
values.length >= 2 && 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) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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,86 @@ 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();
|
||||||
|
// return !appsPageKey.currentState?.clearSelected();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
467
lib/pages/import_export.dart
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
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/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) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Exported to $path')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 'Invalid input';
|
||||||
|
}
|
||||||
|
appsProvider
|
||||||
|
.importApps(data)
|
||||||
|
.then((value) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User canceled the picker
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
}).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) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${urls.length} Apps')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength: urls.length,
|
||||||
|
errors: errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Import from URL List',
|
||||||
|
)),
|
||||||
|
...sourceProvider.massSources
|
||||||
|
.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) {
|
||||||
|
ScaffoldMessenger
|
||||||
|
.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${selectedUrls.length} Apps')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showDialog(
|
||||||
|
context:
|
||||||
|
context,
|
||||||
|
builder:
|
||||||
|
(BuildContext
|
||||||
|
ctx) {
|
||||||
|
return ImportErrorDialog(
|
||||||
|
urlsLength:
|
||||||
|
selectedUrls
|
||||||
|
.length,
|
||||||
|
errors:
|
||||||
|
errors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catchError((e) {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e.toString())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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,263 @@ 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(
|
return Scaffold(
|
||||||
padding: const EdgeInsets.all(16),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
child: settingsProvider.prefs == null
|
body: CustomScrollView(slivers: <Widget>[
|
||||||
? Container()
|
const CustomAppBar(title: 'Settings'),
|
||||||
: Column(
|
SliverToBoxAdapter(
|
||||||
children: [
|
child: Padding(
|
||||||
DropdownButtonFormField(
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: const InputDecoration(labelText: 'Theme'),
|
child: settingsProvider.prefs == null
|
||||||
value: settingsProvider.theme,
|
? const SizedBox()
|
||||||
items: const [
|
: Column(
|
||||||
DropdownMenuItem(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
value: ThemeSettings.dark,
|
children: [
|
||||||
child: Text('Dark'),
|
Text(
|
||||||
),
|
'Appearance',
|
||||||
DropdownMenuItem(
|
style: TextStyle(
|
||||||
value: ThemeSettings.light,
|
color: Theme.of(context).colorScheme.primary),
|
||||||
child: Text('Light'),
|
),
|
||||||
),
|
DropdownButtonFormField(
|
||||||
DropdownMenuItem(
|
decoration:
|
||||||
value: ThemeSettings.system,
|
const InputDecoration(labelText: 'Theme'),
|
||||||
child: Text('Follow System'),
|
value: settingsProvider.theme,
|
||||||
)
|
items: const [
|
||||||
],
|
DropdownMenuItem(
|
||||||
onChanged: (value) {
|
value: ThemeSettings.dark,
|
||||||
if (value != null) {
|
child: Text('Dark'),
|
||||||
settingsProvider.theme = value;
|
),
|
||||||
}
|
DropdownMenuItem(
|
||||||
}),
|
value: ThemeSettings.light,
|
||||||
const SizedBox(
|
child: Text('Light'),
|
||||||
height: 16,
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
DropdownButtonFormField(
|
value: ThemeSettings.system,
|
||||||
decoration: const InputDecoration(labelText: 'Colour'),
|
child: Text('Follow System'),
|
||||||
value: settingsProvider.colour,
|
)
|
||||||
items: const [
|
],
|
||||||
DropdownMenuItem(
|
onChanged: (value) {
|
||||||
value: ColourSettings.basic,
|
if (value != null) {
|
||||||
child: Text('Obtainium'),
|
settingsProvider.theme = value;
|
||||||
),
|
}
|
||||||
DropdownMenuItem(
|
}),
|
||||||
value: ColourSettings.materialYou,
|
const SizedBox(
|
||||||
child: Text('Material You'),
|
height: 16,
|
||||||
)
|
),
|
||||||
],
|
DropdownButtonFormField(
|
||||||
onChanged: (value) {
|
decoration:
|
||||||
if (value != null) {
|
const InputDecoration(labelText: 'Colour'),
|
||||||
settingsProvider.colour = value;
|
value: settingsProvider.colour,
|
||||||
}
|
items: const [
|
||||||
}),
|
DropdownMenuItem(
|
||||||
const SizedBox(
|
value: ColourSettings.basic,
|
||||||
height: 16,
|
child: Text('Obtainium'),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField(
|
DropdownMenuItem(
|
||||||
decoration: const InputDecoration(
|
value: ColourSettings.materialYou,
|
||||||
labelText: 'Background Update Checking Interval'),
|
child: Text('Material You'),
|
||||||
value: settingsProvider.updateInterval,
|
)
|
||||||
items: const [
|
],
|
||||||
DropdownMenuItem(
|
onChanged: (value) {
|
||||||
value: 15,
|
if (value != null) {
|
||||||
child: Text('15 Minutes'),
|
settingsProvider.colour = value;
|
||||||
),
|
}
|
||||||
DropdownMenuItem(
|
}),
|
||||||
value: 30,
|
const SizedBox(
|
||||||
child: Text('30 Minutes'),
|
height: 16,
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
Row(
|
||||||
value: 60,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
child: Text('1 Hour'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
DropdownMenuItem(
|
Expanded(
|
||||||
value: 360,
|
child: DropdownButtonFormField(
|
||||||
child: Text('6 Hours'),
|
decoration: const InputDecoration(
|
||||||
),
|
labelText: 'App Sort By'),
|
||||||
DropdownMenuItem(
|
value: settingsProvider.sortColumn,
|
||||||
value: 720,
|
items: const [
|
||||||
child: Text('12 Hours'),
|
DropdownMenuItem(
|
||||||
),
|
value:
|
||||||
DropdownMenuItem(
|
SortColumnSettings.authorName,
|
||||||
value: 1440,
|
child: Text('Author/Name'),
|
||||||
child: Text('1 Day'),
|
),
|
||||||
),
|
DropdownMenuItem(
|
||||||
],
|
value:
|
||||||
onChanged: (value) {
|
SortColumnSettings.nameAuthor,
|
||||||
if (value != null) {
|
child: Text('Name/Author'),
|
||||||
settingsProvider.updateInterval = value;
|
),
|
||||||
}
|
DropdownMenuItem(
|
||||||
}),
|
value: SortColumnSettings.added,
|
||||||
const SizedBox(
|
child: Text('As Added'),
|
||||||
height: 32,
|
)
|
||||||
),
|
],
|
||||||
Row(
|
onChanged: (value) {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
if (value != null) {
|
||||||
children: [
|
settingsProvider.sortColumn = value;
|
||||||
ElevatedButton(
|
}
|
||||||
onPressed: appsProvider.apps.isEmpty
|
})),
|
||||||
? null
|
const SizedBox(
|
||||||
: () {
|
width: 16,
|
||||||
HapticFeedback.lightImpact();
|
),
|
||||||
appsProvider.exportApps().then((String path) {
|
Expanded(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
child: DropdownButtonFormField(
|
||||||
SnackBar(
|
decoration: const InputDecoration(
|
||||||
content: Text('Exported to $path')),
|
labelText: 'App Sort Order'),
|
||||||
);
|
value: settingsProvider.sortOrder,
|
||||||
});
|
items: const [
|
||||||
},
|
DropdownMenuItem(
|
||||||
child: const Text('Export Apps')),
|
value: SortOrderSettings.ascending,
|
||||||
ElevatedButton(
|
child: Text('Ascending'),
|
||||||
onPressed: () {
|
),
|
||||||
HapticFeedback.lightImpact();
|
DropdownMenuItem(
|
||||||
showDialog(
|
value: SortOrderSettings.descending,
|
||||||
context: context,
|
child: Text('Descending'),
|
||||||
builder: (BuildContext ctx) {
|
),
|
||||||
final formKey = GlobalKey<FormState>();
|
],
|
||||||
final jsonInputController =
|
onChanged: (value) {
|
||||||
TextEditingController();
|
if (value != null) {
|
||||||
|
settingsProvider.sortOrder = value;
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Show Source Webpage in App View'),
|
||||||
|
Switch(
|
||||||
|
value: settingsProvider.showAppWebpage,
|
||||||
|
onChanged: (value) {
|
||||||
|
settingsProvider.showAppWebpage = value;
|
||||||
|
})
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Updates',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText:
|
||||||
|
'Background Update Checking Interval'),
|
||||||
|
value: settingsProvider.updateInterval,
|
||||||
|
items: updateIntervals.map((e) {
|
||||||
|
int displayNum = (e < 60
|
||||||
|
? e
|
||||||
|
: e < 1440
|
||||||
|
? e / 60
|
||||||
|
: e / 1440)
|
||||||
|
.round();
|
||||||
|
var displayUnit = (e < 60
|
||||||
|
? 'Minute'
|
||||||
|
: e < 1440
|
||||||
|
? 'Hour'
|
||||||
|
: 'Day');
|
||||||
|
|
||||||
return AlertDialog(
|
String display = e == 0
|
||||||
scrollable: true,
|
? 'Never - Manual Only'
|
||||||
title: const Text('Import Apps'),
|
: '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}';
|
||||||
content: Column(children: [
|
return DropdownMenuItem(
|
||||||
const Text(
|
value: e, child: Text(display));
|
||||||
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
}).toList(),
|
||||||
Form(
|
onChanged: (value) {
|
||||||
key: formKey,
|
if (value != null) {
|
||||||
child: TextFormField(
|
settingsProvider.updateInterval = value;
|
||||||
minLines: 7,
|
}
|
||||||
maxLines: 7,
|
}),
|
||||||
decoration: const InputDecoration(
|
const SizedBox(
|
||||||
helperText:
|
height: 8,
|
||||||
'Obtainium export data'),
|
),
|
||||||
controller: jsonInputController,
|
Text(
|
||||||
validator: (value) {
|
'Longer intervals recommended for large App collections',
|
||||||
if (value == null ||
|
style: Theme.of(context)
|
||||||
value.isEmpty) {
|
.textTheme
|
||||||
return 'Please enter your Obtainium export data';
|
.labelMedium!
|
||||||
}
|
.merge(const TextStyle(
|
||||||
bool isJSON = true;
|
fontStyle: FontStyle.italic)),
|
||||||
try {
|
),
|
||||||
jsonDecode(value);
|
const Divider(
|
||||||
} catch (e) {
|
height: 48,
|
||||||
isJSON = false;
|
),
|
||||||
}
|
Text(
|
||||||
if (!isJSON) {
|
'Source-Specific',
|
||||||
return 'Invalid input';
|
style: TextStyle(
|
||||||
}
|
color: Theme.of(context).colorScheme.primary),
|
||||||
return null;
|
),
|
||||||
},
|
...sourceProvider.sources.map((e) {
|
||||||
),
|
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
||||||
)
|
return GeneratedForm(
|
||||||
]),
|
items: e.moreSourceSettingsFormItems
|
||||||
actions: [
|
.map((e) => [e])
|
||||||
TextButton(
|
.toList(),
|
||||||
onPressed: () {
|
onValueChanges: (values, valid) {
|
||||||
HapticFeedback.lightImpact();
|
if (valid) {
|
||||||
Navigator.of(context).pop();
|
for (var i = 0;
|
||||||
},
|
i < values.length;
|
||||||
child: const Text('Cancel')),
|
i++) {
|
||||||
TextButton(
|
settingsProvider.setSettingString(
|
||||||
onPressed: () {
|
e.moreSourceSettingsFormItems[i]
|
||||||
HapticFeedback.heavyImpact();
|
.id,
|
||||||
if (formKey.currentState!
|
values[i]);
|
||||||
.validate()) {
|
}
|
||||||
appsProvider
|
}
|
||||||
.importApps(
|
},
|
||||||
jsonInputController
|
defaultValues:
|
||||||
.value.text)
|
e.moreSourceSettingsFormItems.map((e) {
|
||||||
.then((value) {
|
return settingsProvider
|
||||||
ScaffoldMessenger.of(context)
|
.getSettingString(e.id) ??
|
||||||
.showSnackBar(
|
'';
|
||||||
SnackBar(
|
}).toList());
|
||||||
content: Text(
|
} else {
|
||||||
'$value Apps Imported')),
|
return Container();
|
||||||
);
|
}
|
||||||
}).catchError((e) {
|
}),
|
||||||
ScaffoldMessenger.of(context)
|
],
|
||||||
.showSnackBar(
|
))),
|
||||||
SnackBar(
|
SliverToBoxAdapter(
|
||||||
content:
|
child: Column(
|
||||||
Text(e.toString())),
|
children: [
|
||||||
);
|
const SizedBox(
|
||||||
}).whenComplete(() {
|
height: 16,
|
||||||
Navigator.of(context).pop();
|
),
|
||||||
});
|
TextButton.icon(
|
||||||
}
|
style: ButtonStyle(
|
||||||
},
|
foregroundColor: MaterialStateProperty.resolveWith<Color>(
|
||||||
child: const Text('Import')),
|
(Set<MaterialState> states) {
|
||||||
],
|
return Colors.grey;
|
||||||
);
|
}),
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Import Apps'))
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
));
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,27 +5,34 @@ 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: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; // Also indicates that an App is installed
|
||||||
|
|
||||||
AppInMemory(this.app, this.downloadProgress);
|
AppInMemory(this.app, this.downloadProgress, this.installedInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApkFile {
|
class DownloadedApp {
|
||||||
String appId;
|
String appId;
|
||||||
File file;
|
File file;
|
||||||
ApkFile(this.appId, this.file);
|
DownloadedApp(this.appId, this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppsProvider with ChangeNotifier {
|
class AppsProvider with ChangeNotifier {
|
||||||
@ -36,102 +43,304 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// 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(
|
||||||
// Subscribe to changes in the app foreground status
|
{bool shouldLoadApps = false,
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
bool shouldCheckUpdatesAfterLoad = false,
|
||||||
foregroundSubscription = foregroundStream.listen((event) async {
|
bool shouldDeleteAPKs = false}) {
|
||||||
isForeground = event == FGBGType.foreground;
|
if (shouldLoadApps) {
|
||||||
if (isForeground) await loadApps();
|
// Subscribe to changes in the app foreground status
|
||||||
});
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
loadApps();
|
foregroundSubscription = foregroundStream?.listen((event) async {
|
||||||
|
isForeground = event == FGBGType.foreground;
|
||||||
|
if (isForeground) await loadApps();
|
||||||
|
});
|
||||||
|
loadApps().then((_) {
|
||||||
|
if (shouldDeleteAPKs) {
|
||||||
|
deleteSavedAPKs();
|
||||||
|
}
|
||||||
|
if (shouldCheckUpdatesAfterLoad) {
|
||||||
|
checkUpdates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
downloadApk(String apkUrl, String fileName, Function? onProgress,
|
||||||
|
Function? urlModifier,
|
||||||
|
{bool useExistingIfExists = true}) async {
|
||||||
|
var destDir = (await getExternalStorageDirectory())!.path;
|
||||||
|
if (urlModifier != null) {
|
||||||
|
apkUrl = await urlModifier(apkUrl);
|
||||||
|
}
|
||||||
StreamedResponse response =
|
StreamedResponse response =
|
||||||
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
||||||
File downloadFile =
|
File downloadFile = File('$destDir/$fileName.apk');
|
||||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
var alreadyExists = downloadFile.existsSync();
|
||||||
if (downloadFile.existsSync()) {
|
if (!alreadyExists || !useExistingIfExists) {
|
||||||
downloadFile.deleteSync();
|
if (alreadyExists) {
|
||||||
}
|
downloadFile.deleteSync();
|
||||||
var length = response.contentLength;
|
}
|
||||||
var received = 0;
|
|
||||||
var sink = downloadFile.openWrite();
|
|
||||||
|
|
||||||
await response.stream.map((s) {
|
var length = response.contentLength;
|
||||||
received += s.length;
|
var received = 0;
|
||||||
apps[appId]!.downloadProgress =
|
double? progress;
|
||||||
(length != null ? received / length * 100 : 30);
|
var sink = downloadFile.openWrite();
|
||||||
|
|
||||||
|
await response.stream.map((s) {
|
||||||
|
received += s.length;
|
||||||
|
progress = (length != null ? received / length * 100 : 30);
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).pipe(sink);
|
||||||
|
|
||||||
|
await sink.close();
|
||||||
|
progress = null;
|
||||||
|
if (onProgress != null) {
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
downloadFile.deleteSync();
|
||||||
|
throw response.reasonPhrase ?? 'Unknown Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downloadFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloads the App (preferred URL) and returns an ApkFile object
|
||||||
|
// If the app was already saved, updates it's download progress % in memory
|
||||||
|
// But also works for Apps that are not saved
|
||||||
|
Future<DownloadedApp> downloadApp(App app) async {
|
||||||
|
var fileName = '${app.id}-${app.latestVersion}-${app.preferredApkIndex}';
|
||||||
|
File downloadFile = await downloadApk(app.apkUrls[app.preferredApkIndex],
|
||||||
|
'${app.id}-${app.latestVersion}-${app.preferredApkIndex}',
|
||||||
|
(double? progress) {
|
||||||
|
if (apps[app.id] != null) {
|
||||||
|
apps[app.id]!.downloadProgress = progress;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return s;
|
}, SourceProvider().getSource(app.url).apkUrlPrefetchModifier);
|
||||||
}).pipe(sink);
|
// Delete older versions of the APK if any
|
||||||
|
for (var file in downloadFile.parent.listSync()) {
|
||||||
await sink.close();
|
var fn = file.path.split('/').last;
|
||||||
apps[appId]!.downloadProgress = null;
|
if (fn.startsWith('${app.id}-') &&
|
||||||
notifyListeners();
|
fn.endsWith('.apk') &&
|
||||||
|
fn != '$fileName.apk') {
|
||||||
if (response.statusCode != 200) {
|
file.delete();
|
||||||
downloadFile.deleteSync();
|
}
|
||||||
throw response.reasonPhrase ?? 'Unknown Error';
|
|
||||||
}
|
}
|
||||||
return ApkFile(appId, downloadFile);
|
// If the ID has changed (as it should on first download), replace it
|
||||||
|
var newInfo = await PackageArchiveInfo.fromPath(downloadFile.path);
|
||||||
|
if (app.id != newInfo.packageName) {
|
||||||
|
var originalAppId = app.id;
|
||||||
|
app.id = newInfo.packageName;
|
||||||
|
downloadFile = downloadFile.renameSync(
|
||||||
|
'${downloadFile.parent.path}/${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk');
|
||||||
|
if (apps[originalAppId] != null) {
|
||||||
|
await removeApps([originalAppId]);
|
||||||
|
await saveApps([app]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DownloadedApp(app.id, downloadFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// TODO: This is unreliable - try to get from OS in the future
|
||||||
// Returns upon successful download, regardless of installation result
|
var osInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
Future<bool> downloadAndInstallLatestApp(
|
return app.installedVersion != null &&
|
||||||
List<String> appIds, BuildContext context) async {
|
osInfo.version.sdkInt >= 30 &&
|
||||||
|
osInfo.version.release.compareTo('12') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> askUserToReturnToForeground(BuildContext context,
|
||||||
|
{bool waitForFG = false}) async {
|
||||||
NotificationsProvider notificationsProvider =
|
NotificationsProvider notificationsProvider =
|
||||||
context.read<NotificationsProvider>();
|
context.read<NotificationsProvider>();
|
||||||
Map<String, String> appsToInstall = {};
|
if (!isForeground) {
|
||||||
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
|
cancelExisting: true);
|
||||||
|
if (waitForFG) {
|
||||||
|
await FGBGEvents.stream.first == FGBGType.foreground;
|
||||||
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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(DownloadedApp 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 'Can\'t install an older version';
|
||||||
|
}
|
||||||
|
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], shouldCorrectInstallStatus: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> selectApkUrl(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];
|
||||||
|
if (app.apkUrls.length > 1 && context != null) {
|
||||||
|
apkUrl = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKPicker(app: app, initVal: apkUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
|
||||||
|
if (apkUrl != null &&
|
||||||
|
Uri.parse(apkUrl).origin != Uri.parse(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<String>> addToErrorMap(
|
||||||
|
Map<String, List<String>> errors, String appId, String error) {
|
||||||
|
var tempIds = errors.remove(error);
|
||||||
|
tempIds ??= [];
|
||||||
|
tempIds.add(appId);
|
||||||
|
errors.putIfAbsent(error, () => tempIds!);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
|
||||||
|
// 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 (var id in appIds) {
|
for (var id in appIds) {
|
||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw 'App not found';
|
throw 'App not found';
|
||||||
}
|
}
|
||||||
String? apkUrl = apps[id]!.app.apkUrls.last;
|
|
||||||
if (apps[id]!.app.apkUrls.length > 1) {
|
String? apkUrl = await selectApkUrl(apps[id]!.app, context);
|
||||||
apkUrl = await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext ctx) {
|
|
||||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (apkUrl != null) {
|
if (apkUrl != null) {
|
||||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
|
apps[id]!.app.preferredApkIndex = urlInd;
|
||||||
|
await saveApps([apps[id]!.app]);
|
||||||
|
}
|
||||||
|
if (context != null ||
|
||||||
|
(await canInstallSilently(apps[id]!.app) &&
|
||||||
|
apps[id]!.app.apkUrls.length == 1)) {
|
||||||
|
appsToInstall.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, List<String>> errors = {};
|
||||||
|
|
||||||
|
List<DownloadedApp?> downloadedFiles =
|
||||||
|
await Future.wait(appsToInstall.map((id) async {
|
||||||
|
try {
|
||||||
|
return await downloadApp(apps[id]!.app);
|
||||||
|
} catch (e) {
|
||||||
|
addToErrorMap(errors, id, e.toString());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
downloadedFiles =
|
||||||
|
downloadedFiles.where((element) => element != null).toList();
|
||||||
|
|
||||||
|
List<DownloadedApp> silentUpdates = [];
|
||||||
|
List<DownloadedApp> regularInstalls = [];
|
||||||
|
for (var f in downloadedFiles) {
|
||||||
|
bool willBeSilent = await canInstallSilently(apps[f!.appId]!.app);
|
||||||
|
if (willBeSilent) {
|
||||||
|
silentUpdates.add(f);
|
||||||
|
} else {
|
||||||
|
regularInstalls.add(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
// If Obtainium is being installed, it should be the last one
|
||||||
.map((entry) => downloadApp(entry.value, entry.key)));
|
List<DownloadedApp> moveObtainiumToEnd(List<DownloadedApp> items) {
|
||||||
|
String obtainiumId = 'imranr98_obtainium_${GitHub().host}';
|
||||||
if (!isForeground) {
|
DownloadedApp? temp;
|
||||||
await notificationsProvider.notify(completeInstallationNotification,
|
items.removeWhere((element) {
|
||||||
cancelExisting: true);
|
bool res = element.appId == obtainiumId;
|
||||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
if (res) {
|
||||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
temp = element;
|
||||||
// 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:
|
return res;
|
||||||
// https://github.com/flutter/flutter/issues/13937
|
});
|
||||||
|
if (temp != null) {
|
||||||
|
items.add(temp!);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
// TODO: Remove below line if silentupdates are ever figured out
|
||||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
regularInstalls.addAll(silentUpdates);
|
||||||
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
|
|
||||||
for (var f in downloadedFiles) {
|
silentUpdates = moveObtainiumToEnd(silentUpdates);
|
||||||
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium');
|
regularInstalls = moveObtainiumToEnd(regularInstalls);
|
||||||
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
|
||||||
await saveApp(apps[f.appId]!.app);
|
// TODO: Uncomment below if silentupdates are ever figured out
|
||||||
|
// for (var u in silentUpdates) {
|
||||||
|
// await installApk(u, silent: true); // Would need to add silent option
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (context != null) {
|
||||||
|
if (regularInstalls.isNotEmpty) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
await askUserToReturnToForeground(context, waitForFG: true);
|
||||||
|
}
|
||||||
|
for (var i in regularInstalls) {
|
||||||
|
try {
|
||||||
|
await installApk(i);
|
||||||
|
} catch (e) {
|
||||||
|
addToErrorMap(errors, i.appId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.isNotEmpty) {
|
||||||
|
String finalError = '';
|
||||||
|
for (var e in errors.keys) {
|
||||||
|
finalError +=
|
||||||
|
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
|
||||||
|
}
|
||||||
|
throw finalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
return downloadedFiles.isNotEmpty;
|
return downloadedFiles.map((e) => e!.appId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> getAppsDir() async {
|
Future<Directory> getAppsDir() async {
|
||||||
@ -143,16 +352,83 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return appsDir;
|
return appsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all stored APKs except those likely to still be needed
|
||||||
Future<void> deleteSavedAPKs() async {
|
Future<void> deleteSavedAPKs() async {
|
||||||
(await getExternalStorageDirectory())
|
List<FileSystemEntity>? apks = (await getExternalStorageDirectory())
|
||||||
?.listSync()
|
?.listSync()
|
||||||
.where((element) => element.path.endsWith('.apk'))
|
.where((element) => element.path.endsWith('.apk'))
|
||||||
.forEach((element) {
|
.toList();
|
||||||
element.deleteSync();
|
if (apks != null && apks.isNotEmpty) {
|
||||||
});
|
for (var apk in apks) {
|
||||||
|
var shouldDelete = true;
|
||||||
|
var temp = apk.path.split('/').last;
|
||||||
|
temp = temp.substring(0, temp.length - 4);
|
||||||
|
var fn = temp.split('-');
|
||||||
|
if (fn.length == 3) {
|
||||||
|
var possibleId = fn[0];
|
||||||
|
var possibleVersion = fn[1];
|
||||||
|
var possibleApkUrlIndex = fn[2];
|
||||||
|
if (apps[possibleId] != null) {
|
||||||
|
if (apps[possibleId] != null &&
|
||||||
|
apps[possibleId]?.app != null &&
|
||||||
|
apps[possibleId]!.app.installedVersion !=
|
||||||
|
apps[possibleId]!.app.latestVersion &&
|
||||||
|
apps[possibleId]!.app.latestVersion == possibleVersion &&
|
||||||
|
apps[possibleId]!.app.preferredApkIndex.toString() ==
|
||||||
|
possibleApkUrlIndex) {
|
||||||
|
shouldDelete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDelete) apk.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadApps() async {
|
Future<AppInfo?> getInstalledInfo(String? packageName) async {
|
||||||
|
if (packageName != null) {
|
||||||
|
try {
|
||||||
|
return await InstalledApps.getAppInfo(packageName);
|
||||||
|
} catch (e) {
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String standardizeVersionString(String versionString) {
|
||||||
|
return versionString.characters
|
||||||
|
.where((p0) => ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.']
|
||||||
|
.contains(p0))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the App says it is installed by installedInfo is null, set it to not installed
|
||||||
|
// If the App says is is not installed but installedInfo exists, try to set it to installed as latest version...
|
||||||
|
// ...if the latestVersion seems to match the version in installedInfo (not guaranteed)
|
||||||
|
App? correctInstallStatus(App app, AppInfo? installedInfo) {
|
||||||
|
var modded = false;
|
||||||
|
if (installedInfo == null && app.installedVersion != null) {
|
||||||
|
app.installedVersion = null;
|
||||||
|
modded = true;
|
||||||
|
}
|
||||||
|
if (installedInfo != null && app.installedVersion == null) {
|
||||||
|
if (standardizeVersionString(app.latestVersion) ==
|
||||||
|
installedInfo.versionName) {
|
||||||
|
app.installedVersion = app.latestVersion;
|
||||||
|
} else {
|
||||||
|
app.installedVersion = installedInfo.versionName;
|
||||||
|
}
|
||||||
|
modded = true;
|
||||||
|
}
|
||||||
|
return modded ? app : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadApps({shouldCorrectInstallStatus = true}) 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,32 +436,71 @@ 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();
|
||||||
|
// For any that are not installed (by ID == package name), set to not installed if needed
|
||||||
|
if (shouldCorrectInstallStatus) {
|
||||||
|
List<App> modifiedApps = [];
|
||||||
|
for (var app in apps.values) {
|
||||||
|
var moddedApp = correctInstallStatus(app.app, app.installedInfo);
|
||||||
|
if (moddedApp != null) {
|
||||||
|
modifiedApps.add(moddedApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modifiedApps.isNotEmpty) {
|
||||||
|
await saveApps(modifiedApps, shouldCorrectInstallStatus: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveApp(App app) async {
|
Future<void> saveApps(List<App> apps,
|
||||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
{bool shouldCorrectInstallStatus = true}) async {
|
||||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
for (var app in apps) {
|
||||||
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
|
AppInfo? info = await getInstalledInfo(app.id);
|
||||||
ifAbsent: () => AppInMemory(app, null));
|
app.name = info?.name ?? app.name;
|
||||||
|
if (shouldCorrectInstallStatus) {
|
||||||
|
app = correctInstallStatus(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();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeApp(String appId) async {
|
Future<void> removeApps(List<String> appIds) async {
|
||||||
File file = File('${(await getAppsDir()).path}/$appId.json');
|
for (var appId in appIds) {
|
||||||
if (file.existsSync()) {
|
File file = File('${(await getAppsDir()).path}/$appId.json');
|
||||||
file.deleteSync();
|
if (file.existsSync()) {
|
||||||
|
file.deleteSync();
|
||||||
|
}
|
||||||
|
if (apps.containsKey(appId)) {
|
||||||
|
apps.remove(appId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (apps.containsKey(appId)) {
|
if (appIds.isNotEmpty) {
|
||||||
apps.remove(appId);
|
notifyListeners();
|
||||||
}
|
}
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool checkAppObjectForUpdate(App app) {
|
bool checkAppObjectForUpdate(App app) {
|
||||||
@ -195,41 +510,99 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App?> getUpdate(String appId) async {
|
Future<App?> getUpdate(String appId,
|
||||||
|
{bool shouldCorrectInstallStatus = true}) 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],
|
||||||
|
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
|
||||||
|
return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<App>> checkUpdates() async {
|
Future<List<App>> checkUpdates(
|
||||||
|
{DateTime? ignoreAfter,
|
||||||
|
bool immediatelyThrowRateLimitError = false,
|
||||||
|
bool shouldCorrectInstallStatus = true,
|
||||||
|
bool immediatelyThrowSocketError = false}) async {
|
||||||
List<App> updates = [];
|
List<App> updates = [];
|
||||||
|
Map<String, List<String>> errors = {};
|
||||||
if (!gettingUpdates) {
|
if (!gettingUpdates) {
|
||||||
gettingUpdates = true;
|
gettingUpdates = true;
|
||||||
|
|
||||||
List<String> appIds = apps.keys.toList();
|
try {
|
||||||
for (int i = 0; i < appIds.length; i++) {
|
List<String> appIds = apps.keys.toList();
|
||||||
App? newApp = await getUpdate(appIds[i]);
|
if (ignoreAfter != null) {
|
||||||
if (newApp != null) {
|
appIds = appIds
|
||||||
updates.add(newApp);
|
.where((id) =>
|
||||||
|
apps[id]!.app.lastUpdateCheck == null ||
|
||||||
|
apps[id]!.app.lastUpdateCheck!.isBefore(ignoreAfter))
|
||||||
|
.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 getUpdate(appIds[i],
|
||||||
|
shouldCorrectInstallStatus: shouldCorrectInstallStatus);
|
||||||
|
} catch (e) {
|
||||||
|
if (e is RateLimitError && immediatelyThrowRateLimitError) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
if (e is SocketException && immediatelyThrowSocketError) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
var tempIds = errors.remove(e.toString());
|
||||||
|
tempIds ??= [];
|
||||||
|
tempIds.add(appIds[i]);
|
||||||
|
errors.putIfAbsent(e.toString(), () => tempIds!);
|
||||||
|
}
|
||||||
|
if (newApp != null) {
|
||||||
|
updates.add(newApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
gettingUpdates = false;
|
||||||
}
|
}
|
||||||
gettingUpdates = false;
|
}
|
||||||
|
if (errors.isNotEmpty) {
|
||||||
|
String finalError = '';
|
||||||
|
for (var e in errors.keys) {
|
||||||
|
finalError +=
|
||||||
|
'$e ${errors[e]!.map((e) => apps[e]!.app.name).toString()}. ';
|
||||||
|
}
|
||||||
|
throw finalError;
|
||||||
}
|
}
|
||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> getExistingUpdates() {
|
List<String> getExistingUpdates(
|
||||||
|
{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;
|
||||||
@ -254,18 +627,22 @@ class AppsProvider with ChangeNotifier {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,28 +667,30 @@ 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((u) => RadioListTile<String>(
|
||||||
leading: Radio<String>(
|
title: Text(Uri.parse(u)
|
||||||
value: u,
|
.pathSegments
|
||||||
groupValue: apkUrl,
|
.where((element) => element.isNotEmpty)
|
||||||
onChanged: (String? val) {
|
.last),
|
||||||
setState(() {
|
value: u,
|
||||||
apkUrl = val;
|
groupValue: apkUrl,
|
||||||
});
|
onChanged: (String? val) {
|
||||||
})))
|
setState(() {
|
||||||
|
apkUrl = val;
|
||||||
|
});
|
||||||
|
}))
|
||||||
]),
|
]),
|
||||||
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 +698,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') ?? 180;
|
||||||
|
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,22 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get showAppWebpage {
|
||||||
|
return prefs?.getBool('showAppWebpage') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set showAppWebpage(bool show) {
|
||||||
|
prefs?.setBool('showAppWebpage', show);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getSettingString(String settingId) {
|
||||||
|
return prefs?.getString(settingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSettingString(String settingId, String value) {
|
||||||
|
prefs?.setString(settingId, value);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,15 @@
|
|||||||
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/mass_app_sources/githubstars.dart';
|
||||||
|
|
||||||
class AppNames {
|
class AppNames {
|
||||||
late String author;
|
late String author;
|
||||||
@ -29,12 +36,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 +65,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 +84,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) {
|
escapeRegEx(String s) {
|
||||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||||
return "\\${x[0]}";
|
return '\\${x[0]}';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preStandardizeUrl(String url) {
|
||||||
|
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)}';
|
||||||
|
}
|
||||||
|
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 +130,40 @@ 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 MassAppSource {
|
||||||
@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(),
|
||||||
|
// APKMirror()
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add more mass source classes here so they are available via the service
|
||||||
|
List<MassAppSource> massSources = [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}')) {
|
||||||
@ -246,26 +177,56 @@ class SourceProvider {
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
bool doesSourceHaveRequiredAdditionalData(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,
|
||||||
apk.apkUrls);
|
apk.apkUrls,
|
||||||
|
apk.apkUrls.length - 1,
|
||||||
|
additionalData,
|
||||||
|
DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a length 2 list, where the first element is a list of Apps and
|
||||||
|
/// the second is a Map<String, dynamic> of URLs and errors
|
||||||
|
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();
|
||||||
|
163
pubspec.lock
@ -1,13 +1,20 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
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 +71,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 +106,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 +148,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 +187,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 +225,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 +246,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 +261,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 +281,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 +302,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 +310,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 +324,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:
|
||||||
@ -316,56 +386,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 +449,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 +463,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.1.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 +491,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 +519,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 +545,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 +587,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 +601,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 +636,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 +651,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 +678,35 @@ 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:
|
workmanager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: workmanager
|
name: workmanager
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.1"
|
||||||
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"
|
||||||
|
24
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.0+44 # 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
|
workmanager: ^0.5.0
|
||||||
dynamic_color: ^1.5.3
|
dynamic_color: ^1.5.4
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
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);
|
||||||
|