mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 05:16:43 +02:00
Compare commits
24 Commits
v0.7.2-bet
...
v0.8.1-bet
Author | SHA1 | Date | |
---|---|---|---|
086b2b949f | |||
9b5b212e96 | |||
6c8f9ebcbf | |||
4d5773bdcc | |||
f81ef6a416 | |||
47324fcb49 | |||
377e0e07bd | |||
b5aae70274 | |||
42475fa42a | |||
d29534ef2e | |||
25953399ac | |||
b04d2fad5c | |||
868ba84c9a | |||
602f0c3bb2 | |||
00721e8ac4 | |||
d19f9101d6 | |||
a4bc278e4c | |||
b04986622b | |||
2059e4fd44 | |||
618a1523cf | |||
ba1cdc2c73 | |||
aa2a25fffe | |||
c8ec67aef3 | |||
9576a99a4e |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
.vscode/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
58
lib/app_sources/apkmirror.dart
Normal file
58
lib/app_sources/apkmirror.dart
Normal file
@ -0,0 +1,58 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
class APKMirror extends AppSource {
|
||||
APKMirror() {
|
||||
host = 'apkmirror.com';
|
||||
enforceTrackOnly = true;
|
||||
}
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/apk/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw InvalidURLError(runtimeType.toString());
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/#whatsnew';
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/feed'));
|
||||
if (res.statusCode == 200) {
|
||||
String? titleString = parse(res.body)
|
||||
.querySelector('item')
|
||||
?.querySelector('title')
|
||||
?.innerHtml;
|
||||
String? version = titleString
|
||||
?.substring(RegExp('[0-9]').firstMatch(titleString)?.start ?? 0,
|
||||
RegExp(' by ').firstMatch(titleString)?.start ?? 0)
|
||||
.trim();
|
||||
if (version == null || version.isEmpty) {
|
||||
version = titleString;
|
||||
}
|
||||
if (version == null || version.isEmpty) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, []);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
return AppNames(names[1], names[2]);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -28,51 +29,41 @@ class FDroid extends AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
return Uri.parse(standardUrl).pathSegments.last;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
APKDetails getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
Response res, String apkUrlPrefix) {
|
||||
if (res.statusCode == 200) {
|
||||
var releases = parse(res.body).querySelectorAll('.package-version');
|
||||
List<dynamic> releases = jsonDecode(res.body)['packages'] ?? [];
|
||||
if (releases.isEmpty) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String? latestVersion = releases[0]
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(1)
|
||||
.join(' ');
|
||||
String? latestVersion = releases[0]['versionName'];
|
||||
if (latestVersion == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
List<String> apkUrls = releases
|
||||
.where((element) =>
|
||||
element
|
||||
.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.sublist(1)
|
||||
.join(' ') ==
|
||||
latestVersion)
|
||||
.map((e) =>
|
||||
e
|
||||
.querySelector('.package-version-download a')
|
||||
?.attributes['href'] ??
|
||||
'')
|
||||
.where((element) => element.isNotEmpty)
|
||||
.where((element) => element['versionName'] == latestVersion)
|
||||
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
|
||||
.toList();
|
||||
if (apkUrls.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
return APKDetails(latestVersion, apkUrls);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
return getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(Uri.parse('https://f-droid.org/api/v1/packages/$appId')),
|
||||
'https://f-droid.org/repo/$appId');
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
|
@ -11,9 +11,9 @@ class GitHub extends AppSource {
|
||||
GitHub() {
|
||||
host = 'github.com';
|
||||
|
||||
additionalDataDefaults = ['true', 'true', ''];
|
||||
additionalSourceAppSpecificDefaults = ['true', 'true', ''];
|
||||
|
||||
moreSourceSettingsFormItems = [
|
||||
additionalSourceSpecificSettingFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: 'GitHub Personal Access Token (Increases Rate Limit)',
|
||||
id: 'github-creds',
|
||||
@ -51,7 +51,7 @@ class GitHub extends AppSource {
|
||||
])
|
||||
];
|
||||
|
||||
additionalDataFormItems = [
|
||||
additionalSourceAppSpecificFormItems = [
|
||||
[
|
||||
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
|
||||
],
|
||||
@ -96,8 +96,8 @@ class GitHub extends AppSource {
|
||||
Future<String> getCredentialPrefixIfAny() async {
|
||||
SettingsProvider settingsProvider = SettingsProvider();
|
||||
await settingsProvider.initializeSettings();
|
||||
String? creds =
|
||||
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id);
|
||||
String? creds = settingsProvider
|
||||
.getSettingString(additionalSourceSpecificSettingFormItems[0].id);
|
||||
return creds != null && creds.isNotEmpty ? '$creds@' : '';
|
||||
}
|
||||
|
||||
@ -105,12 +105,10 @@ class GitHub extends AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/releases';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
var includePrereleases =
|
||||
additionalData.isNotEmpty && additionalData[0] == 'true';
|
||||
var fallbackToOlderReleases =
|
||||
@ -148,7 +146,7 @@ class GitHub extends AppSource {
|
||||
continue;
|
||||
}
|
||||
var apkUrls = getReleaseAPKUrls(releases[i]);
|
||||
if (apkUrls.isEmpty) {
|
||||
if (apkUrls.isEmpty && !trackOnly) {
|
||||
continue;
|
||||
}
|
||||
targetRelease = releases[i];
|
||||
@ -158,23 +156,14 @@ class GitHub extends AppSource {
|
||||
if (targetRelease == null) {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
String? version = targetRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, targetRelease['apkUrls']);
|
||||
return APKDetails(version, targetRelease['apkUrls'] as List<String>);
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
|
||||
throw NoReleasesError();
|
||||
rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,15 +189,17 @@ class GitHub extends AppSource {
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
throw ObtainiumError(
|
||||
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}',
|
||||
unexpected: true);
|
||||
rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
rateLimitErrorCheck(Response res) {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,12 +23,10 @@ class GitLab extends AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'$standardUrl/-/releases';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
@ -36,7 +34,7 @@ class GitLab extends AppSource {
|
||||
var entry = parsedHtml.querySelector('entry');
|
||||
var entryContent =
|
||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||
var apkUrlList = [
|
||||
var apkUrls = [
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
@ -51,9 +49,6 @@ class GitLab extends AppSource {
|
||||
.where((element) => Uri.parse(element).host != '')
|
||||
.toList()
|
||||
];
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
@ -61,7 +56,7 @@ class GitLab extends AppSource {
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
return APKDetails(version, apkUrls);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
|
||||
@ -22,41 +22,19 @@ class IzzyOnDroid extends AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
return FDroid().tryInferringAppId(standardUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
var multipleVersionApkUrls = parsedHtml
|
||||
.querySelectorAll('a')
|
||||
.where((element) =>
|
||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
||||
false)
|
||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||
.toList();
|
||||
if (multipleVersionApkUrls.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
var version = parsedHtml
|
||||
.querySelector('#keydata')
|
||||
?.querySelectorAll('b')
|
||||
.where(
|
||||
(element) => element.innerHtml.toLowerCase().contains('version'))
|
||||
.toList()[0]
|
||||
.parentNode
|
||||
?.parentNode
|
||||
?.children[1]
|
||||
.innerHtml;
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
String? appId = tryInferringAppId(standardUrl);
|
||||
return FDroid().getAPKUrlsFromFDroidPackagesAPIResponse(
|
||||
await get(
|
||||
Uri.parse('https://apt.izzysoft.de/fdroid/api/v1/packages/$appId')),
|
||||
'https://android.izzysoft.de/frepo/$appId');
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -22,12 +22,10 @@ class Mullvad extends AppSource {
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) =>
|
||||
'https://github.com/mullvad/mullvadvpn-app/blob/master/CHANGELOG.md';
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var version = parse(res.body)
|
||||
|
@ -16,25 +16,21 @@ class Signal extends AppSource {
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res =
|
||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
if (apkUrl == null) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw NoVersionError();
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
return APKDetails(version, apkUrls);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
}
|
||||
|
@ -21,12 +21,10 @@ class SourceForge extends AppSource {
|
||||
@override
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) => null;
|
||||
|
||||
@override
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async => apkUrl;
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) async {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
@ -52,9 +50,6 @@ class SourceForge extends AppSource {
|
||||
apkUrlListAllReleases // This can be used skipped for fallback support later
|
||||
.where((element) => getVersion(element) == version)
|
||||
.toList();
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw NoReleasesError();
|
||||
|
@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
||||
|
||||
enum FormItemType { string, bool }
|
||||
|
||||
typedef OnValueChanges = void Function(List<String> values, bool valid);
|
||||
typedef OnValueChanges = void Function(
|
||||
List<String> values, bool valid, bool isBuilding);
|
||||
|
||||
class GeneratedFormItem {
|
||||
late String key;
|
||||
late String label;
|
||||
late FormItemType type;
|
||||
late bool required;
|
||||
@ -13,6 +15,7 @@ class GeneratedFormItem {
|
||||
late String id;
|
||||
late List<Widget> belowWidgets;
|
||||
late String? hint;
|
||||
late List<String>? opts;
|
||||
|
||||
GeneratedFormItem(
|
||||
{this.label = 'Input',
|
||||
@ -22,7 +25,9 @@ class GeneratedFormItem {
|
||||
this.additionalValidators = const [],
|
||||
this.id = 'input',
|
||||
this.belowWidgets = const [],
|
||||
this.hint});
|
||||
this.hint,
|
||||
this.opts,
|
||||
this.key = 'default'});
|
||||
}
|
||||
|
||||
class GeneratedForm extends StatefulWidget {
|
||||
@ -47,7 +52,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
List<List<Widget>> rows = [];
|
||||
|
||||
// If any value changes, call this to update the parent with value and validity
|
||||
void someValueChanged() {
|
||||
void someValueChanged({bool isBuilding = false}) {
|
||||
List<String> returnValues = [];
|
||||
var valid = true;
|
||||
for (int r = 0; r < values.length; r++) {
|
||||
@ -62,7 +67,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.onValueChanges(returnValues, valid);
|
||||
widget.onValueChanges(returnValues, valid, isBuilding);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -75,14 +80,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
.map((row) => row.map((e) {
|
||||
return j < widget.defaultValues.length
|
||||
? widget.defaultValues[j++]
|
||||
: '';
|
||||
: e.opts != null
|
||||
? e.opts!.first
|
||||
: '';
|
||||
}).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) {
|
||||
if (e.value.type == FormItemType.string && e.value.opts == null) {
|
||||
final formFieldKey = GlobalKey<FormFieldState>();
|
||||
return TextFormField(
|
||||
key: formFieldKey,
|
||||
@ -112,11 +119,29 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
} else if (e.value.type == FormItemType.string &&
|
||||
e.value.opts != null) {
|
||||
if (e.value.opts!.isEmpty) {
|
||||
return const Text('ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT.');
|
||||
}
|
||||
return DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'Colour'),
|
||||
value: values[row.key][e.key],
|
||||
items: e.value.opts!
|
||||
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
values[row.key][e.key] = value ?? e.value.opts!.first;
|
||||
someValueChanged();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return Container(); // Some input types added in build
|
||||
}
|
||||
}).toList();
|
||||
}).toList();
|
||||
someValueChanged(isBuilding: true);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -186,3 +211,18 @@ class _GeneratedFormState extends State<GeneratedForm> {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
String? findGeneratedFormValueByKey(
|
||||
List<GeneratedFormItem> items, List<String> values, String key) {
|
||||
var foundIndex = -1;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].key == key) {
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundIndex >= 0 && foundIndex < values.length) {
|
||||
return values[foundIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
values = widget.defaultValues;
|
||||
valid = widget.initValid;
|
||||
valid = widget.initValid || widget.items.isEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -46,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
|
||||
),
|
||||
GeneratedForm(
|
||||
items: widget.items,
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
this.values = values;
|
||||
this.valid = valid;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
this.values = values;
|
||||
this.valid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: widget.defaultValues)
|
||||
]),
|
||||
|
@ -16,7 +16,7 @@ import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
|
||||
|
||||
const String currentVersion = '0.7.2';
|
||||
const String currentVersion = '0.8.1';
|
||||
const String currentReleaseTag =
|
||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
@ -154,6 +154,7 @@ class _ObtainiumState extends State<Obtainium> {
|
||||
0,
|
||||
['true'],
|
||||
null,
|
||||
false,
|
||||
false)
|
||||
]);
|
||||
}
|
||||
|
@ -27,14 +27,9 @@ class GitHubStars implements MassAppUrlSource {
|
||||
}
|
||||
return urlsWithDescriptions;
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw RateLimitError(
|
||||
(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') /
|
||||
60000000)
|
||||
.round());
|
||||
}
|
||||
|
||||
throw ObtainiumError('Unable to find user\'s starred repos');
|
||||
var gh = GitHub();
|
||||
gh.rateLimitErrorCheck(res);
|
||||
throw getObtainiumHttpError(res);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/pages/import_export.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
@ -21,13 +23,117 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
bool gettingAppInfo = false;
|
||||
|
||||
String userInput = '';
|
||||
String searchQuery = '';
|
||||
AppSource? pickedSource;
|
||||
List<String> additionalData = [];
|
||||
bool validAdditionalData = true;
|
||||
List<String> sourceSpecificAdditionalData = [];
|
||||
bool sourceSpecificDataIsValid = true;
|
||||
List<String> otherAdditionalData = [];
|
||||
bool otherAdditionalDataIsValid = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
changeUserInput(String input, bool valid, bool isBuilding) {
|
||||
userInput = input;
|
||||
fn() {
|
||||
var source = valid ? sourceProvider.getSource(userInput) : null;
|
||||
if (pickedSource != source) {
|
||||
pickedSource = source;
|
||||
sourceSpecificAdditionalData =
|
||||
source != null ? source.additionalSourceAppSpecificDefaults : [];
|
||||
sourceSpecificDataIsValid = source != null
|
||||
? sourceProvider.ifSourceAppsRequireAdditionalData(source)
|
||||
: true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBuilding) {
|
||||
fn();
|
||||
} else {
|
||||
setState(() {
|
||||
fn();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addApp({bool resetUserInputAfter = false}) async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
() async {
|
||||
var userPickedTrackOnly = findGeneratedFormValueByKey(
|
||||
pickedSource!.additionalAppSpecificSourceAgnosticFormItems,
|
||||
otherAdditionalData,
|
||||
'trackOnlyFormItemKey') ==
|
||||
'true';
|
||||
var cont = true;
|
||||
if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) &&
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only',
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
message:
|
||||
'${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.',
|
||||
);
|
||||
}) ==
|
||||
null) {
|
||||
cont = false;
|
||||
}
|
||||
if (cont) {
|
||||
HapticFeedback.selectionClick();
|
||||
var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
|
||||
App app = await sourceProvider.getApp(
|
||||
pickedSource!, userInput, sourceSpecificAdditionalData,
|
||||
trackOnly: trackOnly);
|
||||
if (!trackOnly) {
|
||||
await settingsProvider.getInstallPermission();
|
||||
}
|
||||
// Only download the APK here if you need to for the package ID
|
||||
if (sourceProvider.isTempId(app.id) && !app.trackOnly) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider.confirmApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw ObtainiumError('Cancelled');
|
||||
}
|
||||
app.preferredApkIndex = app.apkUrls.indexOf(apkUrl);
|
||||
var downloadedApk = await appsProvider.downloadApp(app);
|
||||
app.id = downloadedApk.appId;
|
||||
}
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw ObtainiumError('App already added');
|
||||
}
|
||||
if (app.trackOnly) {
|
||||
app.installedVersion = app.latestVersion;
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
}
|
||||
}()
|
||||
.then((app) {
|
||||
if (app != null) {
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (context) => AppPage(appId: app.id)));
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
if (resetUserInputAfter) {
|
||||
changeUserInput('', false, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
@ -66,24 +172,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
])
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
userInput = values[0];
|
||||
var source = valid
|
||||
? sourceProvider.getSource(userInput)
|
||||
: null;
|
||||
if (pickedSource != source) {
|
||||
pickedSource = source;
|
||||
additionalData = source != null
|
||||
? source.additionalDataDefaults
|
||||
: [];
|
||||
validAdditionalData = source != null
|
||||
? sourceProvider
|
||||
.ifSourceAppsRequireAdditionalData(
|
||||
source)
|
||||
: true;
|
||||
}
|
||||
});
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
changeUserInput(
|
||||
values[0], valid, isBuilding);
|
||||
},
|
||||
defaultValues: const [])),
|
||||
const SizedBox(
|
||||
@ -94,68 +185,115 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
: ElevatedButton(
|
||||
onPressed: gettingAppInfo ||
|
||||
pickedSource == null ||
|
||||
(pickedSource!.additionalDataFormItems
|
||||
(pickedSource!
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty &&
|
||||
!validAdditionalData)
|
||||
!sourceSpecificDataIsValid) ||
|
||||
(pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty &&
|
||||
!otherAdditionalDataIsValid)
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
() async {
|
||||
HapticFeedback.selectionClick();
|
||||
App app =
|
||||
await sourceProvider.getApp(
|
||||
pickedSource!,
|
||||
userInput,
|
||||
additionalData);
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
// ignore: use_build_context_synchronously
|
||||
var apkUrl = await appsProvider
|
||||
.confirmApkUrl(app, context);
|
||||
if (apkUrl == null) {
|
||||
throw ObtainiumError('Cancelled');
|
||||
}
|
||||
app.preferredApkIndex =
|
||||
app.apkUrls.indexOf(apkUrl);
|
||||
var downloadedApk =
|
||||
await appsProvider
|
||||
.downloadApp(app);
|
||||
app.id = downloadedApk.appId;
|
||||
if (appsProvider.apps
|
||||
.containsKey(app.id)) {
|
||||
throw ObtainiumError(
|
||||
'App already added');
|
||||
}
|
||||
await appsProvider.saveApps([app]);
|
||||
|
||||
return app;
|
||||
}()
|
||||
.then((app) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(
|
||||
appId: app.id)));
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
: addApp,
|
||||
child: const Text('Add'))
|
||||
],
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
if (sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.isNotEmpty &&
|
||||
pickedSource == null &&
|
||||
userInput.isEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormItem(
|
||||
label: 'Search (Some Sources Only)',
|
||||
required: false),
|
||||
]
|
||||
],
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (values.isNotEmpty && valid) {
|
||||
setState(() {
|
||||
searchQuery = values[0].trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: const ['']),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: searchQuery.isEmpty || gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
Future.wait(sourceProvider.sources
|
||||
.where((e) => e.canSearch)
|
||||
.map((e) =>
|
||||
e.search(searchQuery)))
|
||||
.then((results) async {
|
||||
// Interleave results instead of simple reduce
|
||||
Map<String, String> res = {};
|
||||
var si = 0;
|
||||
var done = false;
|
||||
while (!done) {
|
||||
done = true;
|
||||
for (var r in results) {
|
||||
if (r.length > si) {
|
||||
done = false;
|
||||
res.addEntries(
|
||||
[r.entries.elementAt(si)]);
|
||||
}
|
||||
}
|
||||
si++;
|
||||
}
|
||||
List<String>? selectedUrls = res
|
||||
.isEmpty
|
||||
? []
|
||||
: await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions: res,
|
||||
selectedByDefault: false,
|
||||
onlyOneSelectionAllowed:
|
||||
true,
|
||||
);
|
||||
});
|
||||
if (selectedUrls != null &&
|
||||
selectedUrls.isNotEmpty) {
|
||||
changeUserInput(
|
||||
selectedUrls[0], true, true);
|
||||
addApp(resetUserInputAfter: true);
|
||||
}
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
},
|
||||
child: const Text('Search'))
|
||||
],
|
||||
),
|
||||
if (pickedSource != null &&
|
||||
pickedSource!.additionalDataDefaults.isNotEmpty)
|
||||
(pickedSource!.additionalSourceAppSpecificDefaults
|
||||
.isNotEmpty ||
|
||||
pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.isNotEmpty))
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@ -171,22 +309,51 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
height: 16,
|
||||
),
|
||||
if (pickedSource!
|
||||
.additionalDataFormItems.isNotEmpty)
|
||||
.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
GeneratedForm(
|
||||
items: pickedSource!.additionalDataFormItems,
|
||||
onValueChanges: (values, valid) {
|
||||
setState(() {
|
||||
additionalData = values;
|
||||
validAdditionalData = valid;
|
||||
});
|
||||
items: pickedSource!
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
sourceSpecificAdditionalData = values;
|
||||
sourceSpecificDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues:
|
||||
pickedSource!.additionalDataDefaults),
|
||||
defaultValues: pickedSource!
|
||||
.additionalSourceAppSpecificDefaults),
|
||||
if (pickedSource!
|
||||
.additionalDataFormItems.isNotEmpty)
|
||||
.additionalAppSpecificSourceAgnosticDefaults
|
||||
.isNotEmpty)
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
GeneratedForm(
|
||||
items: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticFormItems
|
||||
.where((e) => pickedSource!.enforceTrackOnly
|
||||
? e.key != 'trackOnlyFormItemKey'
|
||||
: true)
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (isBuilding) {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
} else {
|
||||
setState(() {
|
||||
otherAdditionalData = values;
|
||||
otherAdditionalDataIsValid = valid;
|
||||
});
|
||||
}
|
||||
},
|
||||
defaultValues: pickedSource!
|
||||
.additionalAppSpecificSourceAgnosticDefaults),
|
||||
],
|
||||
)
|
||||
else
|
||||
@ -195,22 +362,24 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 48,
|
||||
),
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
...sourceProvider.sources
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
launchUrlString('https://${e.host}',
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
e,
|
||||
'${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}',
|
||||
style: const TextStyle(
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
@ -218,6 +387,9 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
)))
|
||||
.toList()
|
||||
])),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
])),
|
||||
)
|
||||
]));
|
||||
|
@ -106,7 +106,7 @@ class _AppPageState extends State<AppPage> {
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}${app?.app.trackOnly == true ? ' (Estimate)\n\nApp is Track-Only' : ''}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
@ -140,6 +140,7 @@ class _AppPageState extends State<AppPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.trackOnly == false &&
|
||||
app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
@ -183,7 +184,8 @@ class _AppPageState extends State<AppPage> {
|
||||
tooltip: 'Mark as Updated',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (source != null &&
|
||||
source.additionalDataFormItems.isNotEmpty)
|
||||
source.additionalSourceAppSpecificFormItems
|
||||
.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
@ -194,11 +196,11 @@ class _AppPageState extends State<AppPage> {
|
||||
return GeneratedFormModal(
|
||||
title: 'Additional Options',
|
||||
items: source
|
||||
.additionalDataFormItems,
|
||||
.additionalSourceAppSpecificFormItems,
|
||||
defaultValues: app != null
|
||||
? app.app.additionalData
|
||||
: source
|
||||
.additionalDataDefaults);
|
||||
.additionalSourceAppSpecificDefaults);
|
||||
}).then((values) {
|
||||
if (app != null && values != null) {
|
||||
var changedApp = app.app;
|
||||
@ -221,21 +223,33 @@ class _AppPageState extends State<AppPage> {
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
() async {
|
||||
if (app?.app.trackOnly != true) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
}()
|
||||
.then((value) {
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}).catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
? 'Install'
|
||||
: 'Update'))),
|
||||
? app?.app.trackOnly == false
|
||||
? 'Install'
|
||||
: 'Mark Installed'
|
||||
: app?.app.trackOnly == false
|
||||
? 'Update'
|
||||
: 'Mark Updated'))),
|
||||
const SizedBox(width: 16.0),
|
||||
ElevatedButton(
|
||||
onPressed: app?.downloadProgress != null
|
||||
|
@ -135,6 +135,22 @@ class AppsPageState extends State<AppsPage> {
|
||||
: selectedApps.map((e) => e.id).contains(element))
|
||||
.toList();
|
||||
|
||||
List<String> trackOnlyUpdateIdsAllOrSelected = [];
|
||||
existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) {
|
||||
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) {
|
||||
if (appsProvider.apps[id]!.app.trackOnly) {
|
||||
trackOnlyUpdateIdsAllOrSelected.add(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
if (settingsProvider.pinUpdates) {
|
||||
var temp = [];
|
||||
sortedApps = sortedApps.where((sa) {
|
||||
@ -245,7 +261,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
children: [
|
||||
Text(appsProvider.areDownloadsRunning()
|
||||
? 'Please Wait...'
|
||||
: 'Update Available'),
|
||||
: 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
|
||||
SourceProvider()
|
||||
.getSource(sortedApps[index].app.url)
|
||||
.changeLogPageFromStandardUrl(
|
||||
@ -276,8 +292,7 @@ class AppsPageState extends State<AppsPage> {
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
sortedApps[index].app.installedVersion ??
|
||||
'Not Installed',
|
||||
'${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
|
||||
overflow: TextOverflow.fade,
|
||||
textAlign: TextAlign.end,
|
||||
)))),
|
||||
@ -349,49 +364,77 @@ class AppsPageState extends State<AppsPage> {
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: appsProvider.areDownloadsRunning() ||
|
||||
(existingUpdateIdsAllOrSelected.isEmpty &&
|
||||
newInstallIdsAllOrSelected.isEmpty)
|
||||
newInstallIdsAllOrSelected.isEmpty &&
|
||||
trackOnlyUpdateIdsAllOrSelected.isEmpty)
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
List<List<GeneratedFormItem>> formInputs = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
|
||||
newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add([
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool)
|
||||
]);
|
||||
formInputs.add([
|
||||
GeneratedFormItem(
|
||||
label:
|
||||
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool)
|
||||
]);
|
||||
List<GeneratedFormItem> formInputs = [];
|
||||
List<String> defaultValues = [];
|
||||
if (existingUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add(GeneratedFormItem(
|
||||
label:
|
||||
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool,
|
||||
key: 'updates'));
|
||||
defaultValues.add('true');
|
||||
}
|
||||
if (newInstallIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add(GeneratedFormItem(
|
||||
label:
|
||||
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
|
||||
type: FormItemType.bool,
|
||||
key: 'installs'));
|
||||
defaultValues
|
||||
.add(defaultValues.isEmpty ? 'true' : '');
|
||||
}
|
||||
if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) {
|
||||
formInputs.add(GeneratedFormItem(
|
||||
label:
|
||||
'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated',
|
||||
type: FormItemType.bool,
|
||||
key: 'trackonlies'));
|
||||
defaultValues
|
||||
.add(defaultValues.isEmpty ? 'true' : '');
|
||||
}
|
||||
showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var totalApps = existingUpdateIdsAllOrSelected
|
||||
.length +
|
||||
newInstallIdsAllOrSelected.length +
|
||||
trackOnlyUpdateIdsAllOrSelected.length;
|
||||
return GeneratedFormModal(
|
||||
title:
|
||||
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?',
|
||||
message:
|
||||
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
|
||||
items: formInputs,
|
||||
defaultValues: [
|
||||
'true',
|
||||
existingUpdateIdsAllOrSelected.isEmpty
|
||||
? 'true'
|
||||
: ''
|
||||
],
|
||||
'Change $totalApps App${totalApps == 1 ? '' : 's'}',
|
||||
items: formInputs.map((e) => [e]).toList(),
|
||||
defaultValues: defaultValues,
|
||||
initValid: true,
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
bool shouldInstallUpdates = values[0] == 'true';
|
||||
bool shouldInstallNew = values[1] == 'true';
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
if (values.isEmpty) {
|
||||
values = defaultValues;
|
||||
}
|
||||
bool shouldInstallUpdates =
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'updates') ==
|
||||
'true';
|
||||
bool shouldInstallNew =
|
||||
findGeneratedFormValueByKey(
|
||||
formInputs, values, 'installs') ==
|
||||
'true';
|
||||
bool shouldMarkTrackOnlies =
|
||||
findGeneratedFormValueByKey(formInputs,
|
||||
values, 'trackonlies') ==
|
||||
'true';
|
||||
(() async {
|
||||
if (shouldInstallNew ||
|
||||
shouldInstallUpdates) {
|
||||
await settingsProvider
|
||||
.getInstallPermission();
|
||||
}
|
||||
})()
|
||||
.then((_) {
|
||||
List<String> toInstall = [];
|
||||
if (shouldInstallUpdates) {
|
||||
@ -402,6 +445,10 @@ class AppsPageState extends State<AppsPage> {
|
||||
toInstall
|
||||
.addAll(newInstallIdsAllOrSelected);
|
||||
}
|
||||
if (shouldMarkTrackOnlies) {
|
||||
toInstall.addAll(
|
||||
trackOnlyUpdateIdsAllOrSelected);
|
||||
}
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApps(
|
||||
toInstall, context)
|
||||
|
@ -8,7 +8,6 @@ import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@ -27,7 +26,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
@ -40,24 +38,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
),
|
||||
);
|
||||
|
||||
Future<List<List<String>>> addApps(List<String> urls) async {
|
||||
await settingsProvider.getInstallPermission();
|
||||
List<dynamic> results = await sourceProvider.getApps(urls,
|
||||
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
|
||||
List<App> apps = results[0];
|
||||
Map<String, dynamic> errorsMap = results[1];
|
||||
for (var app in apps) {
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: 'App already added'});
|
||||
} else {
|
||||
await appsProvider.saveApps([app]);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||
return errors;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
@ -197,7 +177,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
addApps(urls).then((errors) {
|
||||
appsProvider
|
||||
.addAppsByURL(urls)
|
||||
.then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
showError(
|
||||
'Imported ${urls.length} Apps',
|
||||
@ -275,7 +257,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
return UrlSelectionModal(
|
||||
urlsWithDescriptions:
|
||||
urlsWithDescriptions,
|
||||
defaultSelected:
|
||||
selectedByDefault:
|
||||
false,
|
||||
);
|
||||
});
|
||||
@ -284,8 +266,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
selectedUrls
|
||||
.isNotEmpty) {
|
||||
var errors =
|
||||
await addApps(
|
||||
selectedUrls);
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
@ -374,8 +357,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
||||
});
|
||||
if (selectedUrls != null) {
|
||||
var errors =
|
||||
await addApps(
|
||||
selectedUrls);
|
||||
await appsProvider
|
||||
.addAppsByURL(
|
||||
selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(
|
||||
@ -486,10 +470,12 @@ class UrlSelectionModal extends StatefulWidget {
|
||||
UrlSelectionModal(
|
||||
{super.key,
|
||||
required this.urlsWithDescriptions,
|
||||
this.defaultSelected = true});
|
||||
this.selectedByDefault = true,
|
||||
this.onlyOneSelectionAllowed = false});
|
||||
|
||||
Map<String, String> urlsWithDescriptions;
|
||||
bool defaultSelected;
|
||||
bool selectedByDefault;
|
||||
bool onlyOneSelectionAllowed;
|
||||
|
||||
@override
|
||||
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
|
||||
@ -501,8 +487,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var url in widget.urlsWithDescriptions.entries) {
|
||||
urlWithDescriptionSelections.putIfAbsent(
|
||||
url, () => widget.defaultSelected);
|
||||
urlWithDescriptionSelections.putIfAbsent(url,
|
||||
() => widget.selectedByDefault && !widget.onlyOneSelectionAllowed);
|
||||
}
|
||||
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(widget.urlsWithDescriptions.entries.first.key);
|
||||
}
|
||||
}
|
||||
|
||||
selectOnlyOne(String url) {
|
||||
for (var uwd in urlWithDescriptionSelections.keys) {
|
||||
urlWithDescriptionSelections[uwd] = uwd.key == url;
|
||||
}
|
||||
}
|
||||
|
||||
@ -510,7 +505,8 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Select URLs to Import'),
|
||||
title:
|
||||
Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'),
|
||||
content: Column(children: [
|
||||
...urlWithDescriptionSelections.keys.map((urlWithD) {
|
||||
return Row(children: [
|
||||
@ -518,7 +514,12 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
value: urlWithDescriptionSelections[urlWithD],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
urlWithDescriptionSelections[urlWithD] = value ?? false;
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(urlWithD.key);
|
||||
} else {
|
||||
urlWithDescriptionSelections[urlWithD] = value!;
|
||||
}
|
||||
});
|
||||
}),
|
||||
const SizedBox(
|
||||
@ -565,14 +566,19 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(urlWithDescriptionSelections.entries
|
||||
.where((entry) => entry.value)
|
||||
.map((e) => e.key.key)
|
||||
.toList());
|
||||
},
|
||||
child: Text(
|
||||
'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
|
||||
onPressed:
|
||||
urlWithDescriptionSelections.values.where((b) => b).isEmpty
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).pop(urlWithDescriptionSelections
|
||||
.entries
|
||||
.where((entry) => entry.value)
|
||||
.map((e) => e.key.key)
|
||||
.toList());
|
||||
},
|
||||
child: Text(widget.onlyOneSelectionAllowed
|
||||
? 'Pick'
|
||||
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/logs_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -139,18 +138,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
|
||||
var sourceSpecificFields = sourceProvider.sources.map((e) {
|
||||
if (e.moreSourceSettingsFormItems.isNotEmpty) {
|
||||
if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
|
||||
return GeneratedForm(
|
||||
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(),
|
||||
onValueChanges: (values, valid) {
|
||||
items: e.additionalSourceSpecificSettingFormItems
|
||||
.map((e) => [e])
|
||||
.toList(),
|
||||
onValueChanges: (values, valid, isBuilding) {
|
||||
if (valid) {
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
settingsProvider.setSettingString(
|
||||
e.moreSourceSettingsFormItems[i].id, values[i]);
|
||||
e.additionalSourceSpecificSettingFormItems[i].id,
|
||||
values[i]);
|
||||
}
|
||||
}
|
||||
},
|
||||
defaultValues: e.moreSourceSettingsFormItems.map((e) {
|
||||
defaultValues: e.additionalSourceSpecificSettingFormItems.map((e) {
|
||||
return settingsProvider.getSettingString(e.id) ?? '';
|
||||
}).toList());
|
||||
} else {
|
||||
@ -264,27 +266,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (logs.isEmpty) {
|
||||
showError(ObtainiumError('No Logs'), context);
|
||||
} else {
|
||||
String logString =
|
||||
logs.map((e) => e.toString()).join('\n\n');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Obtainium App Logs',
|
||||
items: const [],
|
||||
defaultValues: const [],
|
||||
message: logString,
|
||||
initValid: true,
|
||||
);
|
||||
}).then((value) {
|
||||
if (value != null) {
|
||||
Share.share(
|
||||
logs
|
||||
.map((e) => e.toString())
|
||||
.join('\n\n'),
|
||||
subject: 'Obtainium App Logs');
|
||||
}
|
||||
});
|
||||
return const LogsDialog();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -299,3 +285,71 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class LogsDialog extends StatefulWidget {
|
||||
const LogsDialog({super.key});
|
||||
|
||||
@override
|
||||
State<LogsDialog> createState() => _LogsDialogState();
|
||||
}
|
||||
|
||||
class _LogsDialogState extends State<LogsDialog> {
|
||||
String? logString;
|
||||
List<int> days = [7, 5, 4, 3, 2, 1];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var logsProvider = context.read<LogsProvider>();
|
||||
void filterLogs(int days) {
|
||||
logsProvider
|
||||
.get(after: DateTime.now().subtract(Duration(days: days)))
|
||||
.then((value) {
|
||||
setState(() {
|
||||
String l = value.map((e) => e.toString()).join('\n\n');
|
||||
logString = l.isNotEmpty ? l : 'No Logs';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (logString == null) {
|
||||
filterLogs(days.first);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Obtainium App Logs'),
|
||||
content: Column(
|
||||
children: [
|
||||
DropdownButtonFormField(
|
||||
value: days.first,
|
||||
items: days
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text('$e Day${e == 1 ? '' : 's'}'),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (d) {
|
||||
filterLogs(d ?? 7);
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(logString ?? '')
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Close')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Share.share(logString ?? '', subject: 'Obtainium App Logs');
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Share'))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -65,7 +65,9 @@ class AppsProvider with ChangeNotifier {
|
||||
// Delete existing APKs
|
||||
(await getExternalStorageDirectory())
|
||||
?.listSync()
|
||||
.where((element) => element.path.endsWith('.apk'))
|
||||
.where((element) =>
|
||||
element.path.endsWith('.apk') ||
|
||||
element.path.endsWith('.apk.part'))
|
||||
.forEach((apk) {
|
||||
apk.delete();
|
||||
});
|
||||
@ -73,38 +75,39 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
downloadFile(String url, String fileName, Function? onProgress) async {
|
||||
downloadFile(String url, String fileName, Function? onProgress,
|
||||
{bool useExisting = true}) async {
|
||||
var destDir = (await getExternalStorageDirectory())!.path;
|
||||
StreamedResponse response =
|
||||
await Client().send(Request('GET', Uri.parse(url)));
|
||||
File downloadedFile = File('$destDir/$fileName');
|
||||
|
||||
if (downloadedFile.existsSync()) {
|
||||
downloadedFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
double? progress;
|
||||
var sink = downloadedFile.openWrite();
|
||||
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
progress = (length != null ? received / length * 100 : 30);
|
||||
if (!(downloadedFile.existsSync() && useExisting)) {
|
||||
File tempDownloadedFile = File('${downloadedFile.path}.part');
|
||||
if (tempDownloadedFile.existsSync()) {
|
||||
tempDownloadedFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
double? progress;
|
||||
var sink = tempDownloadedFile.openWrite();
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
progress = (length != null ? received / length * 100 : 30);
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
await sink.close();
|
||||
progress = null;
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
|
||||
await sink.close();
|
||||
progress = null;
|
||||
if (onProgress != null) {
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
downloadedFile.deleteSync();
|
||||
throw response.reasonPhrase ?? 'Unknown Error';
|
||||
if (response.statusCode != 200) {
|
||||
tempDownloadedFile.deleteSync();
|
||||
throw response.reasonPhrase ?? 'Unknown Error';
|
||||
}
|
||||
tempDownloadedFile.renameSync(downloadedFile.path);
|
||||
}
|
||||
return downloadedFile;
|
||||
}
|
||||
@ -263,6 +266,7 @@ class AppsProvider with ChangeNotifier {
|
||||
Future<List<String>> downloadAndInstallLatestApps(
|
||||
List<String> appIds, BuildContext? context) async {
|
||||
List<String> appsToInstall = [];
|
||||
List<String> trackOnlyAppsToUpdate = [];
|
||||
// For all specified Apps, filter out those for which:
|
||||
// 1. A URL cannot be picked
|
||||
// 2. That cannot be installed silently (IF no buildContext was given for interactive install)
|
||||
@ -270,7 +274,10 @@ class AppsProvider with ChangeNotifier {
|
||||
if (apps[id] == null) {
|
||||
throw ObtainiumError('App not found');
|
||||
}
|
||||
String? apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||
String? apkUrl;
|
||||
if (!apps[id]!.app.trackOnly) {
|
||||
apkUrl = await confirmApkUrl(apps[id]!.app, context);
|
||||
}
|
||||
if (apkUrl != null) {
|
||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||
@ -281,7 +288,16 @@ class AppsProvider with ChangeNotifier {
|
||||
appsToInstall.add(id);
|
||||
}
|
||||
}
|
||||
if (apps[id]!.app.trackOnly) {
|
||||
trackOnlyAppsToUpdate.add(id);
|
||||
}
|
||||
}
|
||||
// Mark all specified track-only apps as latest
|
||||
saveApps(trackOnlyAppsToUpdate.map((e) {
|
||||
var a = apps[e]!.app;
|
||||
a.installedVersion = a.latestVersion;
|
||||
return a;
|
||||
}).toList());
|
||||
// Download APKs for all Apps to be installed
|
||||
MultiAppMultiError errors = MultiAppMultiError();
|
||||
List<DownloadedApk?> downloadedFiles =
|
||||
@ -388,7 +404,9 @@ class AppsProvider with ChangeNotifier {
|
||||
return null; // Can't correct in the background isolate
|
||||
}
|
||||
var modded = false;
|
||||
if (installedInfo == null && app.installedVersion != null) {
|
||||
if (installedInfo == null &&
|
||||
app.installedVersion != null &&
|
||||
!app.trackOnly) {
|
||||
app.installedVersion = null;
|
||||
modded = true;
|
||||
}
|
||||
@ -424,22 +442,27 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
loadingApps = true;
|
||||
notifyListeners();
|
||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||
List<App> newApps = (await getAppsDir())
|
||||
.listSync()
|
||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||
.map((e) => App.fromJson(jsonDecode(File(e.path).readAsStringSync())))
|
||||
.toList();
|
||||
apps.clear();
|
||||
var idsToDelete = apps.values
|
||||
.map((e) => e.app.id)
|
||||
.toSet()
|
||||
.difference(newApps.map((e) => e.id).toSet());
|
||||
for (var id in idsToDelete) {
|
||||
apps.remove(id);
|
||||
}
|
||||
var sp = SourceProvider();
|
||||
List<List<String>> errors = [];
|
||||
for (int i = 0; i < appFiles.length; i++) {
|
||||
App app =
|
||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
||||
var info = await getInstalledInfo(app.id);
|
||||
for (int i = 0; i < newApps.length; i++) {
|
||||
var info = await getInstalledInfo(newApps[i].id);
|
||||
try {
|
||||
sp.getSource(app.url);
|
||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null, info));
|
||||
sp.getSource(newApps[i].url);
|
||||
apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
|
||||
} catch (e) {
|
||||
errors.add([app.id, app.name, e.toString()]);
|
||||
errors.add([newApps[i].id, newApps[i].name, e.toString()]);
|
||||
}
|
||||
}
|
||||
if (errors.isNotEmpty) {
|
||||
@ -503,8 +526,9 @@ class AppsProvider with ChangeNotifier {
|
||||
currentApp.additionalData,
|
||||
name: currentApp.name,
|
||||
id: currentApp.id,
|
||||
pinned: currentApp.pinned);
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
pinned: currentApp.pinned,
|
||||
trackOnly: currentApp.trackOnly,
|
||||
installedVersion: currentApp.installedVersion);
|
||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||
}
|
||||
@ -611,6 +635,23 @@ class AppsProvider with ChangeNotifier {
|
||||
foregroundSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
|
||||
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
|
||||
ignoreUrls: apps.values.map((e) => e.app.url).toList());
|
||||
List<App> pps = results[0];
|
||||
Map<String, dynamic> errorsMap = results[1];
|
||||
for (var app in pps) {
|
||||
if (apps.containsKey(app.id)) {
|
||||
errorsMap.addAll({app.id: 'App already added'});
|
||||
} else {
|
||||
await saveApps([app]);
|
||||
}
|
||||
}
|
||||
List<List<String>> errors =
|
||||
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
class APKPicker extends StatefulWidget {
|
||||
|
@ -4,6 +4,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:obtainium/app_sources/apkmirror.dart';
|
||||
import 'package:obtainium/app_sources/fdroid.dart';
|
||||
import 'package:obtainium/app_sources/github.dart';
|
||||
import 'package:obtainium/app_sources/gitlab.dart';
|
||||
@ -41,6 +43,7 @@ class App {
|
||||
late List<String> additionalData;
|
||||
late DateTime? lastUpdateCheck;
|
||||
bool pinned = false;
|
||||
bool trackOnly = false;
|
||||
App(
|
||||
this.id,
|
||||
this.url,
|
||||
@ -52,7 +55,8 @@ class App {
|
||||
this.preferredApkIndex,
|
||||
this.additionalData,
|
||||
this.lastUpdateCheck,
|
||||
this.pinned);
|
||||
this.pinned,
|
||||
this.trackOnly);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@ -73,12 +77,15 @@ class App {
|
||||
: List<String>.from(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
|
||||
json['additionalData'] == null
|
||||
? SourceProvider().getSource(json['url']).additionalDataDefaults
|
||||
? SourceProvider()
|
||||
.getSource(json['url'])
|
||||
.additionalSourceAppSpecificDefaults
|
||||
: List<String>.from(jsonDecode(json['additionalData'])),
|
||||
json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
|
||||
json['pinned'] ?? false);
|
||||
json['pinned'] ?? false,
|
||||
json['trackOnly'] ?? false);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -91,7 +98,8 @@ class App {
|
||||
'preferredApkIndex': preferredApkIndex,
|
||||
'additionalData': jsonEncode(additionalData),
|
||||
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
|
||||
'pinned': pinned
|
||||
'pinned': pinned,
|
||||
'trackOnly': trackOnly
|
||||
};
|
||||
}
|
||||
|
||||
@ -134,12 +142,14 @@ List<String> getLinksFromParsedHTML(
|
||||
|
||||
class AppSource {
|
||||
late String host;
|
||||
bool enforceTrackOnly = false;
|
||||
String standardizeURL(String url) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<APKDetails> getLatestAPKDetails(
|
||||
String standardUrl, List<String> additionalData) {
|
||||
String standardUrl, List<String> additionalData,
|
||||
{bool trackOnly = false}) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
@ -147,21 +157,43 @@ class AppSource {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
List<List<GeneratedFormItem>> additionalDataFormItems = [];
|
||||
List<String> additionalDataDefaults = [];
|
||||
List<GeneratedFormItem> moreSourceSettingsFormItems = [];
|
||||
// Different Sources may need different kinds of additional data for Apps
|
||||
List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
|
||||
List<String> additionalSourceAppSpecificDefaults = [];
|
||||
|
||||
// Some additional data may be needed for Apps regardless of Source
|
||||
final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [
|
||||
GeneratedFormItem(
|
||||
label: 'Track-Only',
|
||||
type: FormItemType.bool,
|
||||
key: 'trackOnlyFormItemKey')
|
||||
];
|
||||
final List<String> additionalAppSpecificSourceAgnosticDefaults = [''];
|
||||
|
||||
// Some Sources may have additional settings at the Source level (not specific to Apps) - these use SettingsProvider
|
||||
List<GeneratedFormItem> additionalSourceSpecificSettingFormItems = [];
|
||||
|
||||
String? changeLogPageFromStandardUrl(String standardUrl) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) {
|
||||
throw NotImplementedError();
|
||||
Future<String> apkUrlPrefetchModifier(String apkUrl) async {
|
||||
return apkUrl;
|
||||
}
|
||||
|
||||
bool canSearch = false;
|
||||
Future<Map<String, String>> search(String query) {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
String? tryInferringAppId(String standardUrl) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ObtainiumError getObtainiumHttpError(Response res) {
|
||||
return ObtainiumError(
|
||||
res.reasonPhrase ?? 'Error ${res.statusCode.toString()}');
|
||||
}
|
||||
|
||||
abstract class MassAppUrlSource {
|
||||
@ -179,7 +211,8 @@ class SourceProvider {
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
SourceForge()
|
||||
SourceForge(),
|
||||
APKMirror()
|
||||
];
|
||||
|
||||
// Add more mass url source classes here so they are available via the service
|
||||
@ -201,7 +234,7 @@ class SourceProvider {
|
||||
}
|
||||
|
||||
bool ifSourceAppsRequireAdditionalData(AppSource source) {
|
||||
for (var row in source.additionalDataFormItems) {
|
||||
for (var row in source.additionalSourceAppSpecificFormItems) {
|
||||
for (var element in row) {
|
||||
if (element.required) {
|
||||
return true;
|
||||
@ -224,46 +257,56 @@ class SourceProvider {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return getSourceHosts().contains(parts.last);
|
||||
return sources.map((e) => e.host).contains(parts.last);
|
||||
}
|
||||
|
||||
Future<App> getApp(AppSource source, String url, List<String> additionalData,
|
||||
{String name = '', String? id, bool pinned = false}) async {
|
||||
{String name = '',
|
||||
String? id,
|
||||
bool pinned = false,
|
||||
bool trackOnly = false,
|
||||
String? installedVersion}) async {
|
||||
String standardUrl = source.standardizeURL(preStandardizeUrl(url));
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk =
|
||||
await source.getLatestAPKDetails(standardUrl, additionalData);
|
||||
APKDetails apk = await source
|
||||
.getLatestAPKDetails(standardUrl, additionalData, trackOnly: trackOnly);
|
||||
if (apk.apkUrls.isEmpty && !trackOnly) {
|
||||
throw NoAPKError();
|
||||
}
|
||||
String apkVersion = apk.version.replaceAll('/', '-');
|
||||
return App(
|
||||
id ?? generateTempID(names, source),
|
||||
id ??
|
||||
source.tryInferringAppId(standardUrl) ??
|
||||
generateTempID(names, source),
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
name.trim().isNotEmpty
|
||||
? name
|
||||
: names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version.replaceAll('/', '-'),
|
||||
installedVersion,
|
||||
apkVersion,
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1,
|
||||
additionalData,
|
||||
DateTime.now(),
|
||||
pinned);
|
||||
pinned,
|
||||
trackOnly);
|
||||
}
|
||||
|
||||
// Returns errors in [results, errors] instead of throwing them
|
||||
Future<List<dynamic>> getApps(List<String> urls,
|
||||
Future<List<dynamic>> getAppsByURLNaive(List<String> urls,
|
||||
{List<String> ignoreUrls = const []}) async {
|
||||
List<App> apps = [];
|
||||
Map<String, dynamic> errors = {};
|
||||
for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
|
||||
try {
|
||||
var source = getSource(url);
|
||||
apps.add(await getApp(source, url, source.additionalDataDefaults));
|
||||
apps.add(await getApp(
|
||||
source, url, source.additionalSourceAppSpecificDefaults));
|
||||
} catch (e) {
|
||||
errors.addAll(<String, dynamic>{url: e});
|
||||
}
|
||||
}
|
||||
return [apps, errors];
|
||||
}
|
||||
|
||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||
}
|
||||
|
18
pubspec.lock
18
pubspec.lock
@ -21,7 +21,7 @@ packages:
|
||||
name: archive
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.3.4"
|
||||
version: "3.3.5"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -187,7 +187,7 @@ packages:
|
||||
name: flutter_launcher_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.10.0"
|
||||
version: "0.11.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -201,7 +201,7 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "12.0.3"
|
||||
version: "12.0.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -372,7 +372,7 @@ packages:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.21"
|
||||
version: "2.0.22"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -491,7 +491,7 @@ packages:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.3.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -643,14 +643,14 @@ packages:
|
||||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.6"
|
||||
version: "6.1.7"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.21"
|
||||
version: "6.0.22"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -699,7 +699,7 @@ packages:
|
||||
name: uuid
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
version: "3.0.7"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -741,7 +741,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
version: "3.1.2"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.7.2+58 # When changing this, update the tag in main() accordingly
|
||||
version: 0.8.1+64 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.18.2 <3.0.0'
|
||||
@ -62,7 +62,7 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: ^0.10.0
|
||||
flutter_launcher_icons: ^0.11.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
Reference in New Issue
Block a user