Compare commits
45 Commits
v0.1.6-bet
...
v0.3.0-bet
Author | SHA1 | Date | |
---|---|---|---|
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 |
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 |
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 |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
57
lib/app_sources/fdroid.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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 standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
||||||
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
|
if (match == null) {
|
||||||
|
throw notValidURL(runtimeType.toString());
|
||||||
|
}
|
||||||
|
return url.substring(0, match.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = [];
|
||||||
|
}
|
123
lib/app_sources/github.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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://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 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
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", ""];
|
||||||
|
}
|
71
lib/app_sources/gitlab.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
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
|
||||||
|
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 = [];
|
||||||
|
}
|
65
lib/app_sources/izzyondroid.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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
|
||||||
|
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 = [];
|
||||||
|
}
|
51
lib/app_sources/mullvad.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
|
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 = [];
|
||||||
|
}
|
44
lib/app_sources/signal.dart
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
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 = [];
|
||||||
|
}
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
175
lib/components/generated_form.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
GeneratedFormItem(
|
||||||
|
{this.label = "Input",
|
||||||
|
this.type = FormItemType.string,
|
||||||
|
this.required = true,
|
||||||
|
this.max = 1,
|
||||||
|
this.additionalValidators = const []});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? " *" : "")),
|
||||||
|
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: rowInput.value));
|
||||||
|
});
|
||||||
|
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)],
|
||||||
|
))
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
57
lib/components/generated_form_modal.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<List<GeneratedFormItem>> items;
|
||||||
|
final List<String> defaultValues;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||||
|
List<String> values = [];
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(widget.title),
|
||||||
|
content: 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'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/app_sources/github.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,18 +10,19 @@ 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 currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v0.1.6-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v0.3.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
void bgTaskCallback() {
|
||||||
// Background update checking process
|
// Background update checking process
|
||||||
Workmanager().executeTask((task, taskName) async {
|
Workmanager().executeTask((task, taskName) async {
|
||||||
var appsProvider = AppsProvider(bg: true);
|
|
||||||
var notificationsProvider = NotificationsProvider();
|
var notificationsProvider = NotificationsProvider();
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
try {
|
try {
|
||||||
|
var appsProvider = AppsProvider();
|
||||||
await notificationsProvider
|
await notificationsProvider
|
||||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
await appsProvider.loadApps();
|
await appsProvider.loadApps();
|
||||||
@ -43,16 +45,22 @@ void bgTaskCallback() {
|
|||||||
|
|
||||||
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())
|
||||||
],
|
],
|
||||||
@ -71,19 +79,18 @@ 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
|
// Register the background update task according to the user's setting
|
||||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
if (settingsProvider.updateInterval > 0) {
|
||||||
frequency: Duration(minutes: settingsProvider.updateInterval),
|
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
||||||
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
frequency: Duration(minutes: settingsProvider.updateInterval),
|
||||||
constraints: Constraints(networkType: NetworkType.connected),
|
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
||||||
existingWorkPolicy: ExistingWorkPolicy.replace);
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
|
existingWorkPolicy: ExistingWorkPolicy.replace);
|
||||||
|
} else {
|
||||||
|
Workmanager().cancelByUniqueName('bg-update-check');
|
||||||
|
}
|
||||||
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
|
||||||
@ -96,7 +103,8 @@ class MyApp extends StatelessWidget {
|
|||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
[],
|
[],
|
||||||
0));
|
0,
|
||||||
|
["true"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
32
lib/mass_app_sources/githubstars.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.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'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> getUrls(List<String> args) async {
|
||||||
|
if (args.length != requiredArgs.length) {
|
||||||
|
throw 'Wrong number of arguments provided';
|
||||||
|
}
|
||||||
|
Response res =
|
||||||
|
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
|
||||||
|
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 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw 'Unable to find user\'s starred repos';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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,185 @@ 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(
|
makeUrlHttps(
|
||||||
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 = [];
|
||||||
|
validAdditionalData = source != null
|
||||||
|
? sourceProvider
|
||||||
|
.doesSourceHaveRequiredAdditionalData(
|
||||||
|
source)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
}
|
defaultValues: const [])),
|
||||||
},
|
const SizedBox(
|
||||||
child: const Text('Add'),
|
width: 16,
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: gettingAppInfo ||
|
||||||
|
pickedSource == null ||
|
||||||
|
(pickedSource!.additionalDataFormItems
|
||||||
|
.isNotEmpty &&
|
||||||
|
!validAdditionalData)
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
setState(() {
|
||||||
|
gettingAppInfo = true;
|
||||||
|
});
|
||||||
|
sourceProvider
|
||||||
|
.getApp(pickedSource!, userInput,
|
||||||
|
additionalData)
|
||||||
|
.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((_) {
|
||||||
|
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 &&
|
||||||
],
|
(pickedSource!.additionalDataFormItems.isNotEmpty))
|
||||||
),
|
Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
children: [
|
||||||
const Text(
|
const Divider(
|
||||||
'Supported Sources:',
|
height: 64,
|
||||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
),
|
||||||
// style: Theme.of(context).textTheme.bodySmall,
|
Text(
|
||||||
),
|
'Additional Options for ${pickedSource?.runtimeType}',
|
||||||
const SizedBox(
|
style: TextStyle(
|
||||||
height: 8,
|
color:
|
||||||
),
|
Theme.of(context).colorScheme.primary)),
|
||||||
...sourceProvider
|
const SizedBox(
|
||||||
.getSourceHosts()
|
height: 16,
|
||||||
.map((e) => GestureDetector(
|
),
|
||||||
onTap: () {
|
GeneratedForm(
|
||||||
launchUrlString('https://$e',
|
items: pickedSource!.additionalDataFormItems,
|
||||||
mode: LaunchMode.externalApplication);
|
onValueChanges: (values, valid) {
|
||||||
},
|
setState(() {
|
||||||
child: Text(
|
additionalData = values;
|
||||||
e,
|
validAdditionalData = valid;
|
||||||
style: const TextStyle(
|
});
|
||||||
decoration: TextDecoration.underline,
|
},
|
||||||
fontStyle: FontStyle.italic),
|
defaultValues:
|
||||||
)))
|
pickedSource!.additionalDataDefaults)
|
||||||
.toList()
|
],
|
||||||
]),
|
)
|
||||||
if (gettingAppInfo)
|
else
|
||||||
const LinearProgressIndicator()
|
Expanded(
|
||||||
else
|
child: Column(
|
||||||
Container(),
|
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';
|
||||||
|
|
||||||
@ -17,18 +21,68 @@ class _AppPageState extends State<AppPage> {
|
|||||||
@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>();
|
||||||
|
var sourceProvider = SourceProvider();
|
||||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||||
|
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
|
||||||
if (app?.app.installedVersion != null) {
|
if (app?.app.installedVersion != null) {
|
||||||
appsProvider.getUpdate(app!.app.id);
|
appsProvider.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: settingsProvider.showAppWebpage
|
||||||
body: WebView(
|
? WebView(
|
||||||
initialUrl: app?.app.url,
|
initialUrl: app?.app.url,
|
||||||
javascriptMode: JavascriptMode.unrestricted,
|
javascriptMode: JavascriptMode.unrestricted,
|
||||||
),
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
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 +94,107 @@ class _AppPageState extends State<AppPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
|
if (app?.app.installedVersion != app?.app.latestVersion)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
|
||||||
|
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
|
||||||
|
.saveApp(updatedApp);
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Yes, Mark as Installed'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Mark as Installed',
|
||||||
|
icon: const Icon(Icons.done))
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('App Not Installed?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('No')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
var updatedApp = app?.app;
|
||||||
|
if (updatedApp != null) {
|
||||||
|
updatedApp.installedVersion =
|
||||||
|
null;
|
||||||
|
appsProvider
|
||||||
|
.saveApp(updatedApp);
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Yes, Mark as Not Installed'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Mark as Not Installed',
|
||||||
|
icon: const Icon(Icons.no_cell_outlined)),
|
||||||
|
if (source != null &&
|
||||||
|
source.additionalDataFormItems.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return GeneratedFormModal(
|
||||||
|
title: 'Additional Options',
|
||||||
|
items: source.additionalDataFormItems,
|
||||||
|
defaultValues:
|
||||||
|
source.additionalDataDefaults);
|
||||||
|
}).then((values) {
|
||||||
|
if (app != null && values != null) {
|
||||||
|
var changedApp = app.app;
|
||||||
|
changedApp.additionalData = values;
|
||||||
|
sourceProvider
|
||||||
|
.getApp(source, changedApp.url,
|
||||||
|
changedApp.additionalData)
|
||||||
|
.then((finalChangedApp) {
|
||||||
|
appsProvider.saveApp(finalChangedApp);
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
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 ||
|
||||||
@ -67,7 +222,6 @@ 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) {
|
||||||
@ -78,7 +232,8 @@ class _AppPageState extends State<AppPage> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback
|
||||||
|
.selectionClick();
|
||||||
appsProvider
|
appsProvider
|
||||||
.removeApp(app!.app.id)
|
.removeApp(app!.app.id)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
@ -91,7 +246,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 +254,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,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/components/custom_app_bar.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/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';
|
||||||
@ -13,70 +16,194 @@ class AppsPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppsPageState extends State<AppsPage> {
|
class _AppsPageState extends State<AppsPage> {
|
||||||
|
AppsFilter? filter;
|
||||||
|
|
||||||
@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>();
|
||||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
||||||
|
var sortedApps = appsProvider.apps.values.toList();
|
||||||
|
|
||||||
|
if (filter != null) {
|
||||||
|
sortedApps = sortedApps.where((app) {
|
||||||
|
if (app.app.installedVersion == app.app.latestVersion &&
|
||||||
|
filter!.onlyNonLatest) {
|
||||||
|
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) {
|
||||||
|
if (!app.app.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) {
|
||||||
|
int result = 0;
|
||||||
|
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
||||||
|
result =
|
||||||
|
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
|
||||||
|
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
||||||
|
result =
|
||||||
|
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
|
||||||
|
sortedApps = sortedApps.reversed.toList();
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
? null
|
floatingActionButton:
|
||||||
: ElevatedButton.icon(
|
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||||
onPressed: appsProvider.areDownloadsRunning()
|
existingUpdateAppIds.isEmpty || filter != null
|
||||||
? null
|
? const SizedBox()
|
||||||
: () {
|
: ElevatedButton.icon(
|
||||||
HapticFeedback.heavyImpact();
|
onPressed: appsProvider.areDownloadsRunning()
|
||||||
context
|
? null
|
||||||
.read<SettingsProvider>()
|
: () {
|
||||||
.getInstallPermission()
|
HapticFeedback.heavyImpact();
|
||||||
.then((_) {
|
settingsProvider.getInstallPermission().then((_) {
|
||||||
appsProvider.downloadAndInstallLatestApp(
|
appsProvider.downloadAndInstallLatestApp(
|
||||||
existingUpdateAppIds, context);
|
existingUpdateAppIds, context);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.install_mobile_outlined),
|
||||||
|
label: const Text('Install All')),
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
),
|
||||||
|
appsProvider.apps.isEmpty
|
||||||
|
? const SizedBox()
|
||||||
|
: ElevatedButton.icon(
|
||||||
|
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: "Ignore Up-to-Date Apps",
|
||||||
|
type: FormItemType.bool)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
defaultValues: filter == null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
filter!.nameFilter,
|
||||||
|
filter!.authorFilter,
|
||||||
|
filter!.onlyNonLatest ? 'true' : ''
|
||||||
|
]);
|
||||||
|
}).then((values) {
|
||||||
|
if (values != null &&
|
||||||
|
values
|
||||||
|
.where((element) => element.isNotEmpty)
|
||||||
|
.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
filter = AppsFilter(
|
||||||
|
nameFilter: values[0],
|
||||||
|
authorFilter: values[1],
|
||||||
|
onlyNonLatest: values[2] == "true");
|
||||||
});
|
});
|
||||||
},
|
} else {
|
||||||
icon: const Icon(Icons.update),
|
setState(() {
|
||||||
label: const Text('Update All')),
|
filter = null;
|
||||||
body: Center(
|
});
|
||||||
child: appsProvider.loadingApps
|
}
|
||||||
? const CircularProgressIndicator()
|
});
|
||||||
: appsProvider.apps.isEmpty
|
},
|
||||||
? Text(
|
label: Text(filter == null ? 'Search' : 'Modify Search'),
|
||||||
'No Apps',
|
icon: Icon(
|
||||||
style: Theme.of(context).textTheme.headline4,
|
filter == null ? Icons.search : Icons.manage_search)),
|
||||||
)
|
]),
|
||||||
: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () {
|
onRefresh: () {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
return appsProvider.checkUpdates();
|
return appsProvider.checkUpdates().catchError((e) {
|
||||||
},
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
child: ListView(
|
SnackBar(content: Text(e.toString())),
|
||||||
children: appsProvider.apps.values
|
);
|
||||||
.map(
|
});
|
||||||
(e) => ListTile(
|
},
|
||||||
title: Text('${e.app.author}/${e.app.name}'),
|
child: CustomScrollView(slivers: <Widget>[
|
||||||
subtitle: Text(
|
const CustomAppBar(title: 'Apps'),
|
||||||
e.app.installedVersion ?? 'Not Installed'),
|
if (appsProvider.loadingApps || sortedApps.isEmpty)
|
||||||
trailing: e.downloadProgress != null
|
SliverFillRemaining(
|
||||||
? Text(
|
child: Center(
|
||||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
child: appsProvider.loadingApps
|
||||||
: (e.app.installedVersion != null &&
|
? const CircularProgressIndicator()
|
||||||
e.app.installedVersion !=
|
: Text(
|
||||||
e.app.latestVersion
|
appsProvider.apps.isEmpty
|
||||||
? const Text('Update Available')
|
? 'No Apps'
|
||||||
: null),
|
: 'No Search Results',
|
||||||
onTap: () {
|
style:
|
||||||
Navigator.push(
|
Theme.of(context).textTheme.headlineMedium,
|
||||||
context,
|
))),
|
||||||
MaterialPageRoute(
|
SliverList(
|
||||||
builder: (context) =>
|
delegate: SliverChildBuilderDelegate(
|
||||||
AppPage(appId: e.app.id)),
|
(BuildContext context, int index) {
|
||||||
);
|
return ListTile(
|
||||||
},
|
title: Text(sortedApps[index].app.name),
|
||||||
),
|
subtitle: Text('By ${sortedApps[index].app.author}'),
|
||||||
)
|
trailing: sortedApps[index].downloadProgress != null
|
||||||
.toList(),
|
? Text(
|
||||||
),
|
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||||
),
|
: (sortedApps[index].app.installedVersion != null &&
|
||||||
));
|
sortedApps[index].app.installedVersion !=
|
||||||
|
sortedApps[index].app.latestVersion
|
||||||
|
? const Text('Update Available')
|
||||||
|
: Text(sortedApps[index].app.installedVersion ??
|
||||||
|
'Not Installed')),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
AppPage(appId: sortedApps[index].app.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, childCount: sortedApps.length))
|
||||||
|
])));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppsFilter {
|
||||||
|
late String nameFilter;
|
||||||
|
late String authorFilter;
|
||||||
|
late bool onlyNonLatest;
|
||||||
|
|
||||||
|
AppsFilter(
|
||||||
|
{this.nameFilter = "",
|
||||||
|
this.authorFilter = "",
|
||||||
|
this.onlyNonLatest = false});
|
||||||
|
}
|
||||||
|
@ -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,40 +13,78 @@ 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('Apps', Icons.apps, const AppsPage()),
|
||||||
const AddAppPage()
|
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;
|
||||||
}
|
}
|
||||||
|
377
lib/pages/import_export.dart
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
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);
|
||||||
|
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.saveApp(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) {
|
||||||
|
source
|
||||||
|
.getUrls(values)
|
||||||
|
.then((urls) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).whenComplete(() {
|
||||||
|
setState(() {
|
||||||
|
importInProgress =
|
||||||
|
false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}).catchError((e) {
|
||||||
|
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'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,5 @@
|
|||||||
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/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_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 +14,223 @@ 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>();
|
||||||
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(
|
SliverFillRemaining(
|
||||||
children: [
|
hasScrollBody: true,
|
||||||
DropdownButtonFormField(
|
child: Padding(
|
||||||
decoration: const InputDecoration(labelText: 'Theme'),
|
padding: const EdgeInsets.all(16),
|
||||||
value: settingsProvider.theme,
|
child: settingsProvider.prefs == null
|
||||||
items: const [
|
? const SizedBox()
|
||||||
DropdownMenuItem(
|
: Column(
|
||||||
value: ThemeSettings.dark,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Text('Dark'),
|
children: [
|
||||||
),
|
Text(
|
||||||
DropdownMenuItem(
|
'Appearance',
|
||||||
value: ThemeSettings.light,
|
style: TextStyle(
|
||||||
child: Text('Light'),
|
color: Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
DropdownButtonFormField(
|
||||||
value: ThemeSettings.system,
|
decoration:
|
||||||
child: Text('Follow System'),
|
const InputDecoration(labelText: 'Theme'),
|
||||||
)
|
value: settingsProvider.theme,
|
||||||
],
|
items: const [
|
||||||
onChanged: (value) {
|
DropdownMenuItem(
|
||||||
if (value != null) {
|
value: ThemeSettings.dark,
|
||||||
settingsProvider.theme = value;
|
child: Text('Dark'),
|
||||||
}
|
),
|
||||||
}),
|
DropdownMenuItem(
|
||||||
const SizedBox(
|
value: ThemeSettings.light,
|
||||||
height: 16,
|
child: Text('Light'),
|
||||||
),
|
),
|
||||||
DropdownButtonFormField(
|
DropdownMenuItem(
|
||||||
decoration: const InputDecoration(labelText: 'Colour'),
|
value: ThemeSettings.system,
|
||||||
value: settingsProvider.colour,
|
child: Text('Follow System'),
|
||||||
items: const [
|
)
|
||||||
DropdownMenuItem(
|
],
|
||||||
value: ColourSettings.basic,
|
onChanged: (value) {
|
||||||
child: Text('Obtainium'),
|
if (value != null) {
|
||||||
),
|
settingsProvider.theme = value;
|
||||||
DropdownMenuItem(
|
}
|
||||||
value: ColourSettings.materialYou,
|
}),
|
||||||
child: Text('Material You'),
|
const SizedBox(
|
||||||
)
|
height: 16,
|
||||||
],
|
),
|
||||||
onChanged: (value) {
|
DropdownButtonFormField(
|
||||||
if (value != null) {
|
decoration:
|
||||||
settingsProvider.colour = value;
|
const InputDecoration(labelText: 'Colour'),
|
||||||
}
|
value: settingsProvider.colour,
|
||||||
}),
|
items: const [
|
||||||
const SizedBox(
|
DropdownMenuItem(
|
||||||
height: 16,
|
value: ColourSettings.basic,
|
||||||
),
|
child: Text('Obtainium'),
|
||||||
DropdownButtonFormField(
|
),
|
||||||
decoration: const InputDecoration(
|
DropdownMenuItem(
|
||||||
labelText: 'Background Update Checking Interval'),
|
value: ColourSettings.materialYou,
|
||||||
value: settingsProvider.updateInterval,
|
child: Text('Material You'),
|
||||||
items: const [
|
)
|
||||||
DropdownMenuItem(
|
],
|
||||||
value: 15,
|
onChanged: (value) {
|
||||||
child: Text('15 Minutes'),
|
if (value != null) {
|
||||||
),
|
settingsProvider.colour = value;
|
||||||
DropdownMenuItem(
|
}
|
||||||
value: 30,
|
}),
|
||||||
child: Text('30 Minutes'),
|
const SizedBox(
|
||||||
),
|
height: 16,
|
||||||
DropdownMenuItem(
|
),
|
||||||
value: 60,
|
Row(
|
||||||
child: Text('1 Hour'),
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
DropdownMenuItem(
|
children: [
|
||||||
value: 360,
|
Expanded(
|
||||||
child: Text('6 Hours'),
|
child: DropdownButtonFormField(
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
DropdownMenuItem(
|
labelText: 'App Sort By'),
|
||||||
value: 720,
|
value: settingsProvider.sortColumn,
|
||||||
child: Text('12 Hours'),
|
items: const [
|
||||||
),
|
DropdownMenuItem(
|
||||||
DropdownMenuItem(
|
value:
|
||||||
value: 1440,
|
SortColumnSettings.authorName,
|
||||||
child: Text('1 Day'),
|
child: Text('Author/Name'),
|
||||||
),
|
),
|
||||||
],
|
DropdownMenuItem(
|
||||||
onChanged: (value) {
|
value:
|
||||||
if (value != null) {
|
SortColumnSettings.nameAuthor,
|
||||||
settingsProvider.updateInterval = value;
|
child: Text('Name/Author'),
|
||||||
}
|
),
|
||||||
}),
|
DropdownMenuItem(
|
||||||
const SizedBox(
|
value: SortColumnSettings.added,
|
||||||
height: 32,
|
child: Text('As Added'),
|
||||||
),
|
)
|
||||||
Row(
|
],
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
onChanged: (value) {
|
||||||
children: [
|
if (value != null) {
|
||||||
ElevatedButton(
|
settingsProvider.sortColumn = value;
|
||||||
onPressed: appsProvider.apps.isEmpty
|
}
|
||||||
? null
|
})),
|
||||||
: () {
|
const SizedBox(
|
||||||
HapticFeedback.lightImpact();
|
width: 16,
|
||||||
appsProvider.exportApps().then((String path) {
|
),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
Expanded(
|
||||||
SnackBar(
|
child: DropdownButtonFormField(
|
||||||
content: Text('Exported to $path')),
|
decoration: const InputDecoration(
|
||||||
);
|
labelText: 'App Sort Order'),
|
||||||
});
|
value: settingsProvider.sortOrder,
|
||||||
},
|
items: const [
|
||||||
child: const Text('Export Apps')),
|
DropdownMenuItem(
|
||||||
ElevatedButton(
|
value: SortOrderSettings.ascending,
|
||||||
onPressed: () {
|
child: Text('Ascending'),
|
||||||
HapticFeedback.lightImpact();
|
),
|
||||||
showDialog(
|
DropdownMenuItem(
|
||||||
context: context,
|
value: SortOrderSettings.descending,
|
||||||
builder: (BuildContext ctx) {
|
child: Text('Descending'),
|
||||||
final formKey = GlobalKey<FormState>();
|
),
|
||||||
final jsonInputController =
|
],
|
||||||
TextEditingController();
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
return AlertDialog(
|
settingsProvider.sortOrder = value;
|
||||||
scrollable: true,
|
}
|
||||||
title: const Text('Import Apps'),
|
})),
|
||||||
content: Column(children: [
|
],
|
||||||
const Text(
|
),
|
||||||
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
const SizedBox(
|
||||||
Form(
|
height: 16,
|
||||||
key: formKey,
|
),
|
||||||
child: TextFormField(
|
Row(
|
||||||
minLines: 7,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
maxLines: 7,
|
children: [
|
||||||
decoration: const InputDecoration(
|
const Text('Show Source Webpage in App View'),
|
||||||
helperText:
|
Switch(
|
||||||
'Obtainium export data'),
|
value: settingsProvider.showAppWebpage,
|
||||||
controller: jsonInputController,
|
onChanged: (value) {
|
||||||
validator: (value) {
|
settingsProvider.showAppWebpage = value;
|
||||||
if (value == null ||
|
})
|
||||||
value.isEmpty) {
|
],
|
||||||
return 'Please enter your Obtainium export data';
|
),
|
||||||
}
|
const Divider(
|
||||||
bool isJSON = true;
|
height: 16,
|
||||||
try {
|
),
|
||||||
jsonDecode(value);
|
const SizedBox(
|
||||||
} catch (e) {
|
height: 16,
|
||||||
isJSON = false;
|
),
|
||||||
}
|
Text(
|
||||||
if (!isJSON) {
|
'More',
|
||||||
return 'Invalid input';
|
style: TextStyle(
|
||||||
}
|
color: Theme.of(context).colorScheme.primary),
|
||||||
return null;
|
),
|
||||||
},
|
DropdownButtonFormField(
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
)
|
labelText:
|
||||||
]),
|
'Background Update Checking Interval'),
|
||||||
actions: [
|
value: settingsProvider.updateInterval,
|
||||||
TextButton(
|
items: const [
|
||||||
onPressed: () {
|
DropdownMenuItem(
|
||||||
HapticFeedback.lightImpact();
|
value: 15,
|
||||||
Navigator.of(context).pop();
|
child: Text('15 Minutes'),
|
||||||
},
|
),
|
||||||
child: const Text('Cancel')),
|
DropdownMenuItem(
|
||||||
TextButton(
|
value: 30,
|
||||||
onPressed: () {
|
child: Text('30 Minutes'),
|
||||||
HapticFeedback.heavyImpact();
|
),
|
||||||
if (formKey.currentState!
|
DropdownMenuItem(
|
||||||
.validate()) {
|
value: 60,
|
||||||
appsProvider
|
child: Text('1 Hour'),
|
||||||
.importApps(
|
),
|
||||||
jsonInputController
|
DropdownMenuItem(
|
||||||
.value.text)
|
value: 360,
|
||||||
.then((value) {
|
child: Text('6 Hours'),
|
||||||
ScaffoldMessenger.of(context)
|
),
|
||||||
.showSnackBar(
|
DropdownMenuItem(
|
||||||
SnackBar(
|
value: 720,
|
||||||
content: Text(
|
child: Text('12 Hours'),
|
||||||
'$value Apps Imported')),
|
),
|
||||||
);
|
DropdownMenuItem(
|
||||||
}).catchError((e) {
|
value: 1440,
|
||||||
ScaffoldMessenger.of(context)
|
child: Text('1 Day'),
|
||||||
.showSnackBar(
|
),
|
||||||
SnackBar(
|
DropdownMenuItem(
|
||||||
content:
|
value: 0,
|
||||||
Text(e.toString())),
|
child: Text('Never - Manual Only'),
|
||||||
);
|
),
|
||||||
}).whenComplete(() {
|
],
|
||||||
Navigator.of(context).pop();
|
onChanged: (value) {
|
||||||
});
|
if (value != null) {
|
||||||
}
|
settingsProvider.updateInterval = value;
|
||||||
},
|
}
|
||||||
child: const Text('Import')),
|
}),
|
||||||
],
|
const Spacer(),
|
||||||
);
|
Row(
|
||||||
});
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
},
|
children: [
|
||||||
child: const Text('Import Apps'))
|
TextButton.icon(
|
||||||
],
|
style: ButtonStyle(
|
||||||
),
|
foregroundColor:
|
||||||
const Spacer(),
|
MaterialStateProperty.resolveWith<
|
||||||
Row(
|
Color>((Set<MaterialState> states) {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
return Colors.grey;
|
||||||
children: [
|
}),
|
||||||
TextButton.icon(
|
),
|
||||||
style: ButtonStyle(
|
onPressed: () {
|
||||||
foregroundColor:
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
MaterialStateProperty.resolveWith<Color>(
|
mode: LaunchMode.externalApplication);
|
||||||
(Set<MaterialState> states) {
|
},
|
||||||
return Colors.grey;
|
icon: const Icon(Icons.code),
|
||||||
}),
|
label: Text(
|
||||||
),
|
'Source',
|
||||||
onPressed: () {
|
style:
|
||||||
HapticFeedback.lightImpact();
|
Theme.of(context).textTheme.bodySmall,
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
),
|
||||||
mode: LaunchMode.externalApplication);
|
)
|
||||||
},
|
],
|
||||||
icon: const Icon(Icons.code),
|
),
|
||||||
label: Text(
|
],
|
||||||
'Source',
|
)))
|
||||||
style: Theme.of(context).textTheme.caption,
|
]));
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
53
lib/pages/test_page.dart
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:obtainium/components/generated_form.dart';
|
||||||
|
|
||||||
|
class TestPage extends StatefulWidget {
|
||||||
|
const TestPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TestPage> createState() => _TestPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestPageState extends State<TestPage> {
|
||||||
|
List<String?>? sourceSpecificData;
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
List<List<GeneratedFormItem>> sourceSpecificInputs = [
|
||||||
|
[GeneratedFormItem(label: 'Test Item 1')],
|
||||||
|
[
|
||||||
|
GeneratedFormItem(label: 'Test Item 2', required: false),
|
||||||
|
GeneratedFormItem(label: 'Test Item 3')
|
||||||
|
],
|
||||||
|
[GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)]
|
||||||
|
];
|
||||||
|
|
||||||
|
List<String> defaultInputValues = ["ABC"];
|
||||||
|
|
||||||
|
void onSourceSpecificDataChanges(
|
||||||
|
List<String?> valuesFromForm, bool formValid) {
|
||||||
|
setState(() {
|
||||||
|
sourceSpecificData = valuesFromForm;
|
||||||
|
valid = formValid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Test Page')),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(children: [
|
||||||
|
GeneratedForm(
|
||||||
|
items: sourceSpecificInputs,
|
||||||
|
onValueChanges: onSourceSpecificDataChanges,
|
||||||
|
defaultValues: defaultInputValues,
|
||||||
|
),
|
||||||
|
...(sourceSpecificData != null
|
||||||
|
? (sourceSpecificData as List<String?>)
|
||||||
|
.map((e) => Text(e ?? ""))
|
||||||
|
: [Container()])
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
@ -39,14 +39,26 @@ class AppsProvider with ChangeNotifier {
|
|||||||
late Stream<FGBGType> foregroundStream;
|
late Stream<FGBGType> foregroundStream;
|
||||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
late StreamSubscription<FGBGType> foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider({bool bg = false}) {
|
AppsProvider(
|
||||||
|
{bool shouldLoadApps = false,
|
||||||
|
bool shouldCheckUpdatesAfterLoad = false,
|
||||||
|
bool shouldDeleteAPKs = false}) {
|
||||||
// Subscribe to changes in the app foreground status
|
// Subscribe to changes in the app foreground status
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
foregroundSubscription = foregroundStream.listen((event) async {
|
foregroundSubscription = foregroundStream.listen((event) async {
|
||||||
isForeground = event == FGBGType.foreground;
|
isForeground = event == FGBGType.foreground;
|
||||||
if (isForeground) await loadApps();
|
if (isForeground) await loadApps();
|
||||||
});
|
});
|
||||||
loadApps();
|
if (shouldDeleteAPKs) {
|
||||||
|
deleteSavedAPKs();
|
||||||
|
}
|
||||||
|
if (shouldLoadApps) {
|
||||||
|
loadApps().then((_) {
|
||||||
|
if (shouldCheckUpdatesAfterLoad) {
|
||||||
|
checkUpdates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
||||||
@ -96,6 +108,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw 'App not found';
|
throw 'App not found';
|
||||||
}
|
}
|
||||||
|
// If the App has more than one APK, the user should pick one
|
||||||
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
||||||
if (apps[id]!.app.apkUrls.length > 1) {
|
if (apps[id]!.app.apkUrls.length > 1) {
|
||||||
apkUrl = await showDialog(
|
apkUrl = await showDialog(
|
||||||
@ -104,6 +117,19 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// If the picked APK comes from an origin different from the source, get user confirmation
|
||||||
|
if (apkUrl != null &&
|
||||||
|
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
|
||||||
|
if (await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKOriginWarningDialog(
|
||||||
|
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
|
||||||
|
}) !=
|
||||||
|
true) {
|
||||||
|
apkUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (apkUrl != null) {
|
if (apkUrl != null) {
|
||||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
@ -202,7 +228,11 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<App?> getUpdate(String appId) async {
|
Future<App?> getUpdate(String appId) async {
|
||||||
App? currentApp = apps[appId]!.app;
|
App? currentApp = apps[appId]!.app;
|
||||||
App newApp = await SourceProvider().getApp(currentApp.url);
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
|
App newApp = await sourceProvider.getApp(
|
||||||
|
sourceProvider.getSource(currentApp.url),
|
||||||
|
currentApp.url,
|
||||||
|
currentApp.additionalData);
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
if (newApp.latestVersion != currentApp.latestVersion) {
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
newApp.installedVersion = currentApp.installedVersion;
|
||||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
@ -313,13 +343,12 @@ class _APKPickerState extends State<APKPicker> {
|
|||||||
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'))
|
||||||
@ -327,3 +356,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'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -9,6 +9,10 @@ enum ThemeSettings { system, light, dark }
|
|||||||
|
|
||||||
enum ColourSettings { basic, materialYou }
|
enum ColourSettings { basic, materialYou }
|
||||||
|
|
||||||
|
enum SortColumnSettings { added, nameAuthor, authorName }
|
||||||
|
|
||||||
|
enum SortOrderSettings { ascending, descending }
|
||||||
|
|
||||||
class SettingsProvider with ChangeNotifier {
|
class SettingsProvider with ChangeNotifier {
|
||||||
SharedPreferences? prefs;
|
SharedPreferences? prefs;
|
||||||
|
|
||||||
@ -45,7 +49,27 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.added.index];
|
||||||
|
}
|
||||||
|
|
||||||
|
set sortColumn(SortColumnSettings s) {
|
||||||
|
prefs?.setInt('sortColumn', s.index);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
SortOrderSettings get sortOrder {
|
||||||
|
return SortOrderSettings.values[
|
||||||
|
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
|
||||||
|
}
|
||||||
|
|
||||||
|
set sortOrder(SortOrderSettings s) {
|
||||||
|
prefs?.setInt('sortOrder', s.index);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,4 +93,13 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get showAppWebpage {
|
||||||
|
return prefs?.getBool('showAppWebpage') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set showAppWebpage(bool show) {
|
||||||
|
prefs?.setBool('showAppWebpage', show);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,14 @@
|
|||||||
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/components/generated_form.dart';
|
||||||
|
import 'package:obtainium/mass_app_sources/githubstars.dart';
|
||||||
|
|
||||||
class AppNames {
|
class AppNames {
|
||||||
late String author;
|
late String author;
|
||||||
@ -30,8 +36,17 @@ class App {
|
|||||||
late String latestVersion;
|
late String latestVersion;
|
||||||
List<String> apkUrls = [];
|
List<String> apkUrls = [];
|
||||||
late int preferredApkIndex;
|
late int preferredApkIndex;
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
late List<String> additionalData;
|
||||||
this.latestVersion, this.apkUrls, this.preferredApkIndex);
|
App(
|
||||||
|
this.id,
|
||||||
|
this.url,
|
||||||
|
this.author,
|
||||||
|
this.name,
|
||||||
|
this.installedVersion,
|
||||||
|
this.latestVersion,
|
||||||
|
this.apkUrls,
|
||||||
|
this.preferredApkIndex,
|
||||||
|
this.additionalData);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@ -39,19 +54,21 @@ class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['url'] as String,
|
json['url'] as String,
|
||||||
json['author'] as String,
|
json['author'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
json['installedVersion'] == null
|
json['installedVersion'] == null
|
||||||
? 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
|
||||||
json['preferredApkIndex'] == null
|
? []
|
||||||
? 0
|
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
: json['preferredApkIndex'] as int,
|
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||||
);
|
json['additionalData'] == null
|
||||||
|
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
||||||
|
: List<String>.from(jsonDecode(json['additionalData'])));
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -61,7 +78,8 @@ class App {
|
|||||||
'installedVersion': installedVersion,
|
'installedVersion': installedVersion,
|
||||||
'latestVersion': latestVersion,
|
'latestVersion': latestVersion,
|
||||||
'apkUrls': jsonEncode(apkUrls),
|
'apkUrls': jsonEncode(apkUrls),
|
||||||
'preferredApkIndex': preferredApkIndex
|
'preferredApkIndex': preferredApkIndex,
|
||||||
|
'additionalData': jsonEncode(additionalData)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +89,26 @@ escapeRegEx(String s) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeUrlHttps(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
|
||||||
@ -85,245 +123,35 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
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 FDroid implements AppSource {
|
|
||||||
@override
|
|
||||||
late String host = 'f-droid.org';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
|
||||||
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 {
|
|
||||||
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 'No APK found';
|
|
||||||
}
|
|
||||||
var version = latestReleaseDiv
|
|
||||||
?.querySelector('.package-version-header b')
|
|
||||||
?.innerHtml
|
|
||||||
.split(' ')
|
|
||||||
.last;
|
|
||||||
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) {
|
|
||||||
var name = Uri.parse(standardUrl).pathSegments.last;
|
|
||||||
return AppNames(name, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) 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 'Could not determine the latest release version';
|
|
||||||
}
|
|
||||||
return APKDetails(
|
|
||||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SourceProvider {
|
class SourceProvider {
|
||||||
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), 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()
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 = makeUrlHttps(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}')) {
|
||||||
@ -337,18 +165,23 @@ 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);
|
Future<App> getApp(
|
||||||
String standardUrl = source.standardizeURL(url);
|
AppSource source, String url, List<String> additionalData) async {
|
||||||
|
String standardUrl = source.standardizeURL(makeUrlHttps(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}',
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
||||||
standardUrl,
|
standardUrl,
|
||||||
@ -357,7 +190,24 @@ class SourceProvider {
|
|||||||
null,
|
null,
|
||||||
apk.version,
|
apk.version,
|
||||||
apk.apkUrls,
|
apk.apkUrls,
|
||||||
apk.apkUrls.length - 1);
|
apk.apkUrls.length - 1,
|
||||||
|
additionalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) async {
|
||||||
|
List<App> apps = [];
|
||||||
|
Map<String, dynamic> errors = {};
|
||||||
|
for (var url in urls) {
|
||||||
|
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();
|
||||||
|
95
pubspec.lock
@ -1,6 +1,13 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
animations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: animations
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -92,13 +99,55 @@ 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: "4.1.2"
|
||||||
|
device_info_plus_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_linux
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_macos
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_windows
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.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 +169,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.1.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -152,21 +208,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: "10.0.0"
|
||||||
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: "1.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
|
||||||
@ -323,7 +386,7 @@ packages:
|
|||||||
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:
|
||||||
@ -379,7 +442,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:
|
||||||
@ -407,7 +470,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.13"
|
||||||
shared_preferences_ios:
|
shared_preferences_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -435,7 +498,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:
|
||||||
@ -496,7 +559,7 @@ packages:
|
|||||||
name: test_api
|
name: test_api
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.12"
|
version: "0.4.14"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -524,7 +587,7 @@ packages:
|
|||||||
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.19"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -573,7 +636,7 @@ packages:
|
|||||||
name: vector_math
|
name: vector_math
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
webview_flutter:
|
webview_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -587,28 +650,28 @@ packages:
|
|||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.5"
|
version: "2.10.1"
|
||||||
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.3"
|
||||||
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.4"
|
||||||
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.0"
|
||||||
workmanager:
|
workmanager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -639,4 +702,4 @@ packages:
|
|||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
dart: ">=2.19.0-79.0.dev <3.0.0"
|
||||||
flutter: ">=3.1.0-0.0.pre.1036"
|
flutter: ">=3.3.0"
|
||||||
|
17
pubspec.yaml
@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.1.6+7 # When changing this, update the tag in main() accordingly
|
version: 0.3.0+16 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||||
@ -35,21 +35,24 @@ 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: ^10.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
|
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: ^4.1.2
|
||||||
|
file_picker: ^5.1.0
|
||||||
|
animations: ^2.0.4
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@ -62,13 +65,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
|
||||||
|