Compare commits

...

14 Commits

Author SHA1 Message Date
6c8f9ebcbf Ran flutter upgrade 2022-11-25 20:45:49 -05:00
4d5773bdcc Slight UI changes on Add App page 2022-11-25 20:41:57 -05:00
f81ef6a416 Search results now interleaved on Add App page 2022-11-25 20:35:51 -05:00
47324fcb49 Added search bar on Add App page 2022-11-25 20:31:52 -05:00
377e0e07bd Removed unused imports 2022-11-25 19:17:08 -05:00
b5aae70274 Bugfix from prev. commit 2022-11-25 19:13:29 -05:00
42475fa42a Only ask for install perm. for non-track-only apps 2022-11-25 19:07:05 -05:00
d29534ef2e Increment version 2022-11-25 18:56:43 -05:00
25953399ac Re-added APKMirror as a Track-Only source 2022-11-25 18:55:17 -05:00
b04d2fad5c Adds Track-Only App Support (Addresses #119 and Sets Groundwork for #44) (#123)
- All Sources now have a "Track-Only" option that will prevent Obtainium from looking for APKs (though the App must still have a release of some kind so that a version string can be grabbed).
    - These Apps cannot be installed through Obtainium, but update notifications will still be sent.
    - The user needs to manually mark them as updated when appropriate.
    - This addresses issue #119.
    - It also partially addresses #44 by allowing some sources to be configured as "Track-Only"-only. The first such source (APKMirror) will be added later.
- Includes various UI changes to accommodate the above change.
- Also makes App loading a bit more responsive (sending Obtainium to the background then returning will now cause App re-load to pick up changes in App versioning that may have been made in the meantime, for instance through update checking).
2022-11-24 21:12:46 -05:00
868ba84c9a Tiny bugfix 2022-11-20 11:05:33 -05:00
602f0c3bb2 Increment version 2022-11-19 16:26:44 -05:00
00721e8ac4 Added days filter to logs dialog (#117) 2022-11-19 16:20:42 -05:00
d19f9101d6 Added dropdown support to generated form 2022-11-19 15:42:20 -05:00
20 changed files with 712 additions and 276 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
.history .history
.svn/ .svn/
migrate_working_dir/ migrate_working_dir/
.vscode/
# IntelliJ related # IntelliJ related
*.iml *.iml

View File

@ -0,0 +1,55 @@
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) 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(0,
RegExp(' build ( |[0-9])+').firstMatch(titleString)?.start ?? 0)
.split(' ')
.last;
if (version == null) {
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]);
}
}

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -49,9 +48,6 @@ class FDroid extends AppSource {
.where((element) => element['versionName'] == latestVersion) .where((element) => element['versionName'] == latestVersion)
.map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk') .map((e) => '${apkUrlPrefix}_${e['versionCode']}.apk')
.toList(); .toList();
if (apkUrls.isEmpty) {
throw NoAPKError();
}
return APKDetails(latestVersion, apkUrls); return APKDetails(latestVersion, apkUrls);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();

View File

@ -11,9 +11,9 @@ class GitHub extends AppSource {
GitHub() { GitHub() {
host = 'github.com'; host = 'github.com';
additionalDataDefaults = ['true', 'true', '']; additionalSourceAppSpecificDefaults = ['true', 'true', ''];
moreSourceSettingsFormItems = [ additionalSourceSpecificSettingFormItems = [
GeneratedFormItem( GeneratedFormItem(
label: 'GitHub Personal Access Token (Increases Rate Limit)', label: 'GitHub Personal Access Token (Increases Rate Limit)',
id: 'github-creds', id: 'github-creds',
@ -51,7 +51,7 @@ class GitHub extends AppSource {
]) ])
]; ];
additionalDataFormItems = [ additionalSourceAppSpecificFormItems = [
[ [
GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool) GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)
], ],
@ -96,8 +96,8 @@ class GitHub extends AppSource {
Future<String> getCredentialPrefixIfAny() async { Future<String> getCredentialPrefixIfAny() async {
SettingsProvider settingsProvider = SettingsProvider(); SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings(); await settingsProvider.initializeSettings();
String? creds = String? creds = settingsProvider
settingsProvider.getSettingString(moreSourceSettingsFormItems[0].id); .getSettingString(additionalSourceSpecificSettingFormItems[0].id);
return creds != null && creds.isNotEmpty ? '$creds@' : ''; return creds != null && creds.isNotEmpty ? '$creds@' : '';
} }
@ -155,14 +155,11 @@ class GitHub extends AppSource {
if (targetRelease == null) { if (targetRelease == null) {
throw NoReleasesError(); throw NoReleasesError();
} }
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw NoAPKError();
}
String? version = targetRelease['tag_name']; String? version = targetRelease['tag_name'];
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, targetRelease['apkUrls']); return APKDetails(version, targetRelease['apkUrls'] as List<String>);
} else { } else {
rateLimitErrorCheck(res); rateLimitErrorCheck(res);
throw getObtainiumHttpError(res); throw getObtainiumHttpError(res);

View File

@ -33,7 +33,7 @@ class GitLab extends AppSource {
var entry = parsedHtml.querySelector('entry'); var entry = parsedHtml.querySelector('entry');
var entryContent = var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [ var apkUrls = [
...getLinksFromParsedHTML( ...getLinksFromParsedHTML(
entryContent, entryContent,
RegExp( RegExp(
@ -48,9 +48,6 @@ class GitLab extends AppSource {
.where((element) => Uri.parse(element).host != '') .where((element) => Uri.parse(element).host != '')
.toList() .toList()
]; ];
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
var entryId = entry?.querySelector('id')?.innerHtml; var entryId = entry?.querySelector('id')?.innerHtml;
var version = var version =
@ -58,7 +55,7 @@ class GitLab extends AppSource {
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrls);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();
} }

View File

@ -1,4 +1,3 @@
import 'package:html/parser.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:obtainium/app_sources/fdroid.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';

View File

@ -24,14 +24,12 @@ class Signal extends AppSource {
if (res.statusCode == 200) { if (res.statusCode == 200) {
var json = jsonDecode(res.body); var json = jsonDecode(res.body);
String? apkUrl = json['url']; String? apkUrl = json['url'];
if (apkUrl == null) { List<String> apkUrls = apkUrl == null ? [] : [apkUrl];
throw NoAPKError();
}
String? version = json['versionName']; String? version = json['versionName'];
if (version == null) { if (version == null) {
throw NoVersionError(); throw NoVersionError();
} }
return APKDetails(version, [apkUrl]); return APKDetails(version, apkUrls);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();
} }

View File

@ -49,9 +49,6 @@ class SourceForge extends AppSource {
apkUrlListAllReleases // This can be used skipped for fallback support later apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version) .where((element) => getVersion(element) == version)
.toList(); .toList();
if (apkUrlList.isEmpty) {
throw NoAPKError();
}
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrlList);
} else { } else {
throw NoReleasesError(); throw NoReleasesError();

View File

@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
enum FormItemType { string, bool } 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 { class GeneratedFormItem {
late String key;
late String label; late String label;
late FormItemType type; late FormItemType type;
late bool required; late bool required;
@ -13,6 +15,7 @@ class GeneratedFormItem {
late String id; late String id;
late List<Widget> belowWidgets; late List<Widget> belowWidgets;
late String? hint; late String? hint;
late List<String>? opts;
GeneratedFormItem( GeneratedFormItem(
{this.label = 'Input', {this.label = 'Input',
@ -22,7 +25,9 @@ class GeneratedFormItem {
this.additionalValidators = const [], this.additionalValidators = const [],
this.id = 'input', this.id = 'input',
this.belowWidgets = const [], this.belowWidgets = const [],
this.hint}); this.hint,
this.opts,
this.key = 'default'});
} }
class GeneratedForm extends StatefulWidget { class GeneratedForm extends StatefulWidget {
@ -47,7 +52,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
List<List<Widget>> rows = []; List<List<Widget>> rows = [];
// If any value changes, call this to update the parent with value and validity // If any value changes, call this to update the parent with value and validity
void someValueChanged() { void someValueChanged({bool isBuilding = false}) {
List<String> returnValues = []; List<String> returnValues = [];
var valid = true; var valid = true;
for (int r = 0; r < values.length; r++) { 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 @override
@ -75,14 +80,16 @@ class _GeneratedFormState extends State<GeneratedForm> {
.map((row) => row.map((e) { .map((row) => row.map((e) {
return j < widget.defaultValues.length return j < widget.defaultValues.length
? widget.defaultValues[j++] ? widget.defaultValues[j++]
: ''; : e.opts != null
? e.opts!.first
: '';
}).toList()) }).toList())
.toList(); .toList();
// Dynamically create form inputs // Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) { formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) { 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>(); final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField( return TextFormField(
key: formFieldKey, key: formFieldKey,
@ -112,11 +119,29 @@ class _GeneratedFormState extends State<GeneratedForm> {
return null; 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 { } else {
return Container(); // Some input types added in build return Container(); // Some input types added in build
} }
}).toList(); }).toList();
}).toList(); }).toList();
someValueChanged(isBuilding: true);
} }
@override @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;
}

View File

@ -29,7 +29,7 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
void initState() { void initState() {
super.initState(); super.initState();
values = widget.defaultValues; values = widget.defaultValues;
valid = widget.initValid; valid = widget.initValid || widget.items.isEmpty;
} }
@override @override
@ -46,11 +46,16 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
), ),
GeneratedForm( GeneratedForm(
items: widget.items, items: widget.items,
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { if (isBuilding) {
this.values = values; this.values = values;
this.valid = valid; this.valid = valid;
}); } else {
setState(() {
this.values = values;
this.valid = valid;
});
}
}, },
defaultValues: widget.defaultValues) defaultValues: widget.defaultValues)
]), ]),

View File

@ -16,7 +16,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart';
const String currentVersion = '0.7.4'; const String currentVersion = '0.8.0';
const String currentReleaseTag = const String currentReleaseTag =
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@ -154,6 +154,7 @@ class _ObtainiumState extends State<Obtainium> {
0, 0,
['true'], ['true'],
null, null,
false,
false) false)
]); ]);
} }

View File

@ -2,8 +2,10 @@ 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/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.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/custom_errors.dart';
import 'package:obtainium/pages/app.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/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
@ -21,13 +23,117 @@ class _AddAppPageState extends State<AddAppPage> {
bool gettingAppInfo = false; bool gettingAppInfo = false;
String userInput = ''; String userInput = '';
String searchQuery = '';
AppSource? pickedSource; AppSource? pickedSource;
List<String> additionalData = []; List<String> sourceSpecificAdditionalData = [];
bool validAdditionalData = true; bool sourceSpecificDataIsValid = true;
List<String> otherAdditionalData = [];
bool otherAdditionalDataIsValid = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); 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( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -66,24 +172,9 @@ class _AddAppPageState extends State<AddAppPage> {
]) ])
] ]
], ],
onValueChanges: (values, valid) { onValueChanges: (values, valid, isBuilding) {
setState(() { changeUserInput(
userInput = values[0]; values[0], valid, isBuilding);
var source = valid
? sourceProvider.getSource(userInput)
: null;
if (pickedSource != source) {
pickedSource = source;
additionalData = source != null
? source.additionalDataDefaults
: [];
validAdditionalData = source != null
? sourceProvider
.ifSourceAppsRequireAdditionalData(
source)
: true;
}
});
}, },
defaultValues: const [])), defaultValues: const [])),
const SizedBox( const SizedBox(
@ -94,73 +185,115 @@ class _AddAppPageState extends State<AddAppPage> {
: ElevatedButton( : ElevatedButton(
onPressed: gettingAppInfo || onPressed: gettingAppInfo ||
pickedSource == null || pickedSource == null ||
(pickedSource!.additionalDataFormItems (pickedSource!
.additionalSourceAppSpecificFormItems
.isNotEmpty && .isNotEmpty &&
!validAdditionalData) !sourceSpecificDataIsValid) ||
(pickedSource!
.additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty &&
!otherAdditionalDataIsValid)
? null ? null
: () async { : addApp,
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();
// Only download the APK here if you need to for the package ID
if (sourceProvider
.isTempId(app.id)) {
// ignore: use_build_context_synchronously
var apkUrl = await appsProvider
.confirmApkUrl(app, context);
if (apkUrl == null) {
throw ObtainiumError(
'Cancelled');
}
app.preferredApkIndex =
app.apkUrls.indexOf(apkUrl);
var downloadedApk =
await appsProvider
.downloadApp(app);
app.id = downloadedApk.appId;
}
if (appsProvider.apps
.containsKey(app.id)) {
throw ObtainiumError(
'App already added');
}
await appsProvider.saveApps([app]);
return app;
}()
.then((app) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(
appId: app.id)));
}).catchError((e) {
showError(e, context);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
},
child: const Text('Add')) 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 && if (pickedSource != null &&
pickedSource!.additionalDataDefaults.isNotEmpty) (pickedSource!.additionalSourceAppSpecificDefaults
.isNotEmpty ||
pickedSource!
.additionalAppSpecificSourceAgnosticFormItems
.where((e) => pickedSource!.enforceTrackOnly
? e.key != 'trackOnlyFormItemKey'
: true)
.map((e) => [e])
.isNotEmpty))
Column( Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -176,22 +309,51 @@ class _AddAppPageState extends State<AddAppPage> {
height: 16, height: 16,
), ),
if (pickedSource! if (pickedSource!
.additionalDataFormItems.isNotEmpty) .additionalSourceAppSpecificFormItems
.isNotEmpty)
GeneratedForm( GeneratedForm(
items: pickedSource!.additionalDataFormItems, items: pickedSource!
onValueChanges: (values, valid) { .additionalSourceAppSpecificFormItems,
setState(() { onValueChanges: (values, valid, isBuilding) {
additionalData = values; if (isBuilding) {
validAdditionalData = valid; sourceSpecificAdditionalData = values;
}); sourceSpecificDataIsValid = valid;
} else {
setState(() {
sourceSpecificAdditionalData = values;
sourceSpecificDataIsValid = valid;
});
}
}, },
defaultValues: defaultValues: pickedSource!
pickedSource!.additionalDataDefaults), .additionalSourceAppSpecificDefaults),
if (pickedSource! if (pickedSource!
.additionalDataFormItems.isNotEmpty) .additionalAppSpecificSourceAgnosticDefaults
.isNotEmpty)
const SizedBox( const SizedBox(
height: 8, 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 else
@ -200,22 +362,24 @@ class _AddAppPageState extends State<AddAppPage> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const SizedBox(
height: 48,
),
const Text( const Text(
'Supported Sources:', 'Supported Sources:',
), ),
const SizedBox( const SizedBox(
height: 8, height: 8,
), ),
...sourceProvider ...sourceProvider.sources
.getSourceHosts()
.map((e) => GestureDetector( .map((e) => GestureDetector(
onTap: () { onTap: () {
launchUrlString('https://$e', launchUrlString('https://${e.host}',
mode: mode:
LaunchMode.externalApplication); LaunchMode.externalApplication);
}, },
child: Text( child: Text(
e, '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}',
style: const TextStyle( style: const TextStyle(
decoration: decoration:
TextDecoration.underline, TextDecoration.underline,
@ -223,6 +387,9 @@ class _AddAppPageState extends State<AddAppPage> {
))) )))
.toList() .toList()
])), ])),
const SizedBox(
height: 8,
),
])), ])),
) )
])); ]));

View File

@ -106,7 +106,7 @@ class _AppPageState extends State<AppPage> {
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
Text( 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, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
@ -140,6 +140,7 @@ class _AppPageState extends State<AppPage> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion != null && if (app?.app.installedVersion != null &&
app?.app.trackOnly == false &&
app?.app.installedVersion != app?.app.latestVersion) app?.app.installedVersion != app?.app.latestVersion)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
@ -183,7 +184,8 @@ class _AppPageState extends State<AppPage> {
tooltip: 'Mark as Updated', tooltip: 'Mark as Updated',
icon: const Icon(Icons.done)), icon: const Icon(Icons.done)),
if (source != null && if (source != null &&
source.additionalDataFormItems.isNotEmpty) source.additionalSourceAppSpecificFormItems
.isNotEmpty)
IconButton( IconButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
@ -194,11 +196,11 @@ class _AppPageState extends State<AppPage> {
return GeneratedFormModal( return GeneratedFormModal(
title: 'Additional Options', title: 'Additional Options',
items: source items: source
.additionalDataFormItems, .additionalSourceAppSpecificFormItems,
defaultValues: app != null defaultValues: app != null
? app.app.additionalData ? app.app.additionalData
: source : source
.additionalDataDefaults); .additionalSourceAppSpecificDefaults);
}).then((values) { }).then((values) {
if (app != null && values != null) { if (app != null && values != null) {
var changedApp = app.app; var changedApp = app.app;
@ -221,21 +223,33 @@ class _AppPageState extends State<AppPage> {
!appsProvider.areDownloadsRunning() !appsProvider.areDownloadsRunning()
? () { ? () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
appsProvider () async {
.downloadAndInstallLatestApps( if (app?.app.trackOnly != true) {
[app!.app.id], await settingsProvider
context).then((res) { .getInstallPermission();
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
} }
}()
.then((value) {
appsProvider
.downloadAndInstallLatestApps(
[app!.app.id],
context).then((res) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
});
}).catchError((e) { }).catchError((e) {
showError(e, context); showError(e, context);
}); });
} }
: null, : null,
child: Text(app?.app.installedVersion == null child: Text(app?.app.installedVersion == null
? 'Install' ? app?.app.trackOnly == false
: 'Update'))), ? 'Install'
: 'Mark Installed'
: app?.app.trackOnly == false
? 'Update'
: 'Mark Updated'))),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
ElevatedButton( ElevatedButton(
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null

View File

@ -135,6 +135,22 @@ class AppsPageState extends State<AppsPage> {
: selectedApps.map((e) => e.id).contains(element)) : selectedApps.map((e) => e.id).contains(element))
.toList(); .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) { if (settingsProvider.pinUpdates) {
var temp = []; var temp = [];
sortedApps = sortedApps.where((sa) { sortedApps = sortedApps.where((sa) {
@ -245,7 +261,7 @@ class AppsPageState extends State<AppsPage> {
children: [ children: [
Text(appsProvider.areDownloadsRunning() Text(appsProvider.areDownloadsRunning()
? 'Please Wait...' ? 'Please Wait...'
: 'Update Available'), : 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'),
SourceProvider() SourceProvider()
.getSource(sortedApps[index].app.url) .getSource(sortedApps[index].app.url)
.changeLogPageFromStandardUrl( .changeLogPageFromStandardUrl(
@ -276,8 +292,7 @@ class AppsPageState extends State<AppsPage> {
child: SizedBox( child: SizedBox(
width: 80, width: 80,
child: Text( child: Text(
sortedApps[index].app.installedVersion ?? '${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}',
'Not Installed',
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
textAlign: TextAlign.end, textAlign: TextAlign.end,
)))), )))),
@ -349,49 +364,77 @@ class AppsPageState extends State<AppsPage> {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() || onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty && (existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty) newInstallIdsAllOrSelected.isEmpty &&
trackOnlyUpdateIdsAllOrSelected.isEmpty)
? null ? null
: () { : () {
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
List<List<GeneratedFormItem>> formInputs = []; List<GeneratedFormItem> formInputs = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty && List<String> defaultValues = [];
newInstallIdsAllOrSelected.isNotEmpty) { if (existingUpdateIdsAllOrSelected.isNotEmpty) {
formInputs.add([ formInputs.add(GeneratedFormItem(
GeneratedFormItem( label:
label: 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', type: FormItemType.bool,
type: FormItemType.bool) key: 'updates'));
]); defaultValues.add('true');
formInputs.add([ }
GeneratedFormItem( if (newInstallIdsAllOrSelected.isNotEmpty) {
label: formInputs.add(GeneratedFormItem(
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', label:
type: FormItemType.bool) '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>?>( showDialog<List<String>?>(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
var totalApps = existingUpdateIdsAllOrSelected
.length +
newInstallIdsAllOrSelected.length +
trackOnlyUpdateIdsAllOrSelected.length;
return GeneratedFormModal( return GeneratedFormModal(
title: title:
'Install${selectedApps.isEmpty ? ' ' : ' Selected '}Apps?', 'Change $totalApps App${totalApps == 1 ? '' : 's'}',
message: items: formInputs.map((e) => [e]).toList(),
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.', defaultValues: defaultValues,
items: formInputs,
defaultValues: [
'true',
existingUpdateIdsAllOrSelected.isEmpty
? 'true'
: ''
],
initValid: true, initValid: true,
); );
}).then((values) { }).then((values) {
if (values != null) { if (values != null) {
bool shouldInstallUpdates = values[0] == 'true'; if (values.isEmpty) {
bool shouldInstallNew = values[1] == 'true'; values = defaultValues;
settingsProvider }
.getInstallPermission() 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((_) { .then((_) {
List<String> toInstall = []; List<String> toInstall = [];
if (shouldInstallUpdates) { if (shouldInstallUpdates) {
@ -402,6 +445,10 @@ class AppsPageState extends State<AppsPage> {
toInstall toInstall
.addAll(newInstallIdsAllOrSelected); .addAll(newInstallIdsAllOrSelected);
} }
if (shouldMarkTrackOnlies) {
toInstall.addAll(
trackOnlyUpdateIdsAllOrSelected);
}
appsProvider appsProvider
.downloadAndInstallLatestApps( .downloadAndInstallLatestApps(
toInstall, context) toInstall, context)

View File

@ -8,7 +8,6 @@ import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@ -27,7 +26,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle( var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all( 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( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[ body: CustomScrollView(slivers: <Widget>[
@ -197,7 +177,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
setState(() { setState(() {
importInProgress = true; importInProgress = true;
}); });
addApps(urls).then((errors) { appsProvider
.addAppsByURL(urls)
.then((errors) {
if (errors.isEmpty) { if (errors.isEmpty) {
showError( showError(
'Imported ${urls.length} Apps', 'Imported ${urls.length} Apps',
@ -275,7 +257,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
return UrlSelectionModal( return UrlSelectionModal(
urlsWithDescriptions: urlsWithDescriptions:
urlsWithDescriptions, urlsWithDescriptions,
defaultSelected: selectedByDefault:
false, false,
); );
}); });
@ -284,8 +266,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
selectedUrls selectedUrls
.isNotEmpty) { .isNotEmpty) {
var errors = var errors =
await addApps( await appsProvider
selectedUrls); .addAppsByURL(
selectedUrls);
if (errors.isEmpty) { if (errors.isEmpty) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
showError( showError(
@ -374,8 +357,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
}); });
if (selectedUrls != null) { if (selectedUrls != null) {
var errors = var errors =
await addApps( await appsProvider
selectedUrls); .addAppsByURL(
selectedUrls);
if (errors.isEmpty) { if (errors.isEmpty) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
showError( showError(
@ -486,10 +470,12 @@ class UrlSelectionModal extends StatefulWidget {
UrlSelectionModal( UrlSelectionModal(
{super.key, {super.key,
required this.urlsWithDescriptions, required this.urlsWithDescriptions,
this.defaultSelected = true}); this.selectedByDefault = true,
this.onlyOneSelectionAllowed = false});
Map<String, String> urlsWithDescriptions; Map<String, String> urlsWithDescriptions;
bool defaultSelected; bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override @override
State<UrlSelectionModal> createState() => _UrlSelectionModalState(); State<UrlSelectionModal> createState() => _UrlSelectionModalState();
@ -501,8 +487,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
void initState() { void initState() {
super.initState(); super.initState();
for (var url in widget.urlsWithDescriptions.entries) { for (var url in widget.urlsWithDescriptions.entries) {
urlWithDescriptionSelections.putIfAbsent( urlWithDescriptionSelections.putIfAbsent(url,
url, () => widget.defaultSelected); () => 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) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: const Text('Select URLs to Import'), title:
Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'),
content: Column(children: [ content: Column(children: [
...urlWithDescriptionSelections.keys.map((urlWithD) { ...urlWithDescriptionSelections.keys.map((urlWithD) {
return Row(children: [ return Row(children: [
@ -518,7 +514,12 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
value: urlWithDescriptionSelections[urlWithD], value: urlWithDescriptionSelections[urlWithD],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
urlWithDescriptionSelections[urlWithD] = value ?? false; value ??= false;
if (value! && widget.onlyOneSelectionAllowed) {
selectOnlyOne(urlWithD.key);
} else {
urlWithDescriptionSelections[urlWithD] = value!;
}
}); });
}), }),
const SizedBox( const SizedBox(
@ -565,14 +566,19 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed:
Navigator.of(context).pop(urlWithDescriptionSelections.entries urlWithDescriptionSelections.values.where((b) => b).isEmpty
.where((entry) => entry.value) ? null
.map((e) => e.key.key) : () {
.toList()); Navigator.of(context).pop(urlWithDescriptionSelections
}, .entries
child: Text( .where((entry) => entry.value)
'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs')) .map((e) => e.key.key)
.toList());
},
child: Text(widget.onlyOneSelectionAllowed
? 'Pick'
: 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs'))
], ],
); );
} }

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.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/custom_errors.dart';
import 'package:obtainium/providers/logs_provider.dart'; import 'package:obtainium/providers/logs_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -139,18 +138,21 @@ class _SettingsPageState extends State<SettingsPage> {
}); });
var sourceSpecificFields = sourceProvider.sources.map((e) { var sourceSpecificFields = sourceProvider.sources.map((e) {
if (e.moreSourceSettingsFormItems.isNotEmpty) { if (e.additionalSourceSpecificSettingFormItems.isNotEmpty) {
return GeneratedForm( return GeneratedForm(
items: e.moreSourceSettingsFormItems.map((e) => [e]).toList(), items: e.additionalSourceSpecificSettingFormItems
onValueChanges: (values, valid) { .map((e) => [e])
.toList(),
onValueChanges: (values, valid, isBuilding) {
if (valid) { if (valid) {
for (var i = 0; i < values.length; i++) { for (var i = 0; i < values.length; i++) {
settingsProvider.setSettingString( 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) ?? ''; return settingsProvider.getSettingString(e.id) ?? '';
}).toList()); }).toList());
} else { } else {
@ -264,27 +266,11 @@ class _SettingsPageState extends State<SettingsPage> {
if (logs.isEmpty) { if (logs.isEmpty) {
showError(ObtainiumError('No Logs'), context); showError(ObtainiumError('No Logs'), context);
} else { } else {
String logString =
logs.map((e) => e.toString()).join('\n\n');
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return GeneratedFormModal( return const LogsDialog();
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');
}
});
} }
}); });
}, },
@ -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'))
],
);
}
}

View File

@ -266,6 +266,7 @@ class AppsProvider with ChangeNotifier {
Future<List<String>> downloadAndInstallLatestApps( Future<List<String>> downloadAndInstallLatestApps(
List<String> appIds, BuildContext? context) async { List<String> appIds, BuildContext? context) async {
List<String> appsToInstall = []; List<String> appsToInstall = [];
List<String> trackOnlyAppsToUpdate = [];
// For all specified Apps, filter out those for which: // For all specified Apps, filter out those for which:
// 1. A URL cannot be picked // 1. A URL cannot be picked
// 2. That cannot be installed silently (IF no buildContext was given for interactive install) // 2. That cannot be installed silently (IF no buildContext was given for interactive install)
@ -273,7 +274,10 @@ class AppsProvider with ChangeNotifier {
if (apps[id] == null) { if (apps[id] == null) {
throw ObtainiumError('App not found'); 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) { 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) {
@ -284,7 +288,16 @@ class AppsProvider with ChangeNotifier {
appsToInstall.add(id); 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 // Download APKs for all Apps to be installed
MultiAppMultiError errors = MultiAppMultiError(); MultiAppMultiError errors = MultiAppMultiError();
List<DownloadedApk?> downloadedFiles = List<DownloadedApk?> downloadedFiles =
@ -391,7 +404,9 @@ class AppsProvider with ChangeNotifier {
return null; // Can't correct in the background isolate return null; // Can't correct in the background isolate
} }
var modded = false; var modded = false;
if (installedInfo == null && app.installedVersion != null) { if (installedInfo == null &&
app.installedVersion != null &&
!app.trackOnly) {
app.installedVersion = null; app.installedVersion = null;
modded = true; modded = true;
} }
@ -445,8 +460,7 @@ class AppsProvider with ChangeNotifier {
var info = await getInstalledInfo(newApps[i].id); var info = await getInstalledInfo(newApps[i].id);
try { try {
sp.getSource(newApps[i].url); sp.getSource(newApps[i].url);
apps.putIfAbsent( apps[newApps[i].id] = AppInMemory(newApps[i], null, info);
newApps[i].id, () => AppInMemory(newApps[i], null, info));
} catch (e) { } catch (e) {
errors.add([newApps[i].id, newApps[i].name, e.toString()]); errors.add([newApps[i].id, newApps[i].name, e.toString()]);
} }
@ -512,8 +526,9 @@ class AppsProvider with ChangeNotifier {
currentApp.additionalData, currentApp.additionalData,
name: currentApp.name, name: currentApp.name,
id: currentApp.id, id: currentApp.id,
pinned: currentApp.pinned); pinned: currentApp.pinned,
newApp.installedVersion = currentApp.installedVersion; trackOnly: currentApp.trackOnly,
installedVersion: currentApp.installedVersion);
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex; newApp.preferredApkIndex = currentApp.preferredApkIndex;
} }
@ -620,6 +635,23 @@ class AppsProvider with ChangeNotifier {
foregroundSubscription?.cancel(); foregroundSubscription?.cancel();
super.dispose(); 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 { class APKPicker extends StatefulWidget {

View File

@ -5,6 +5,7 @@ import 'dart:convert';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.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/fdroid.dart';
import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/app_sources/gitlab.dart';
@ -42,6 +43,7 @@ class App {
late List<String> additionalData; late List<String> additionalData;
late DateTime? lastUpdateCheck; late DateTime? lastUpdateCheck;
bool pinned = false; bool pinned = false;
bool trackOnly = false;
App( App(
this.id, this.id,
this.url, this.url,
@ -53,7 +55,8 @@ class App {
this.preferredApkIndex, this.preferredApkIndex,
this.additionalData, this.additionalData,
this.lastUpdateCheck, this.lastUpdateCheck,
this.pinned); this.pinned,
this.trackOnly);
@override @override
String toString() { String toString() {
@ -74,12 +77,15 @@ class App {
: List<String>.from(jsonDecode(json['apkUrls'])), : List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults ? SourceProvider()
.getSource(json['url'])
.additionalSourceAppSpecificDefaults
: List<String>.from(jsonDecode(json['additionalData'])), : List<String>.from(jsonDecode(json['additionalData'])),
json['lastUpdateCheck'] == null json['lastUpdateCheck'] == null
? null ? null
: DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']),
json['pinned'] ?? false); json['pinned'] ?? false,
json['trackOnly'] ?? false);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -92,7 +98,8 @@ class App {
'preferredApkIndex': preferredApkIndex, 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData), 'additionalData': jsonEncode(additionalData),
'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, 'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch,
'pinned': pinned 'pinned': pinned,
'trackOnly': trackOnly
}; };
} }
@ -135,6 +142,7 @@ List<String> getLinksFromParsedHTML(
class AppSource { class AppSource {
late String host; late String host;
bool enforceTrackOnly = false;
String standardizeURL(String url) { String standardizeURL(String url) {
throw NotImplementedError(); throw NotImplementedError();
} }
@ -148,9 +156,22 @@ class AppSource {
throw NotImplementedError(); throw NotImplementedError();
} }
List<List<GeneratedFormItem>> additionalDataFormItems = []; // Different Sources may need different kinds of additional data for Apps
List<String> additionalDataDefaults = []; List<List<GeneratedFormItem>> additionalSourceAppSpecificFormItems = [];
List<GeneratedFormItem> moreSourceSettingsFormItems = []; 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) { String? changeLogPageFromStandardUrl(String standardUrl) {
throw NotImplementedError(); throw NotImplementedError();
} }
@ -189,7 +210,8 @@ class SourceProvider {
IzzyOnDroid(), IzzyOnDroid(),
Mullvad(), Mullvad(),
Signal(), Signal(),
SourceForge() SourceForge(),
APKMirror()
]; ];
// Add more mass url source classes here so they are available via the service // Add more mass url source classes here so they are available via the service
@ -211,7 +233,7 @@ class SourceProvider {
} }
bool ifSourceAppsRequireAdditionalData(AppSource source) { bool ifSourceAppsRequireAdditionalData(AppSource source) {
for (var row in source.additionalDataFormItems) { for (var row in source.additionalSourceAppSpecificFormItems) {
for (var element in row) { for (var element in row) {
if (element.required) { if (element.required) {
return true; return true;
@ -234,15 +256,23 @@ class SourceProvider {
return false; 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, 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)); String standardUrl = source.standardizeURL(preStandardizeUrl(url));
AppNames names = source.getAppNames(standardUrl); AppNames names = source.getAppNames(standardUrl);
APKDetails apk = APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalData); await source.getLatestAPKDetails(standardUrl, additionalData);
if (apk.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
String apkVersion = apk.version.replaceAll('/', '-');
return App( return App(
id ?? id ??
source.tryInferringAppId(standardUrl) ?? source.tryInferringAppId(standardUrl) ??
@ -252,30 +282,30 @@ class SourceProvider {
name.trim().isNotEmpty name.trim().isNotEmpty
? name ? name
: names.name[0].toUpperCase() + names.name.substring(1), : names.name[0].toUpperCase() + names.name.substring(1),
null, installedVersion,
apk.version.replaceAll('/', '-'), apkVersion,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1, apk.apkUrls.length - 1,
additionalData, additionalData,
DateTime.now(), DateTime.now(),
pinned); pinned,
trackOnly);
} }
// Returns errors in [results, errors] instead of throwing them // 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<String> ignoreUrls = const []}) async {
List<App> apps = []; List<App> apps = [];
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls.where((element) => !ignoreUrls.contains(element))) { for (var url in urls.where((element) => !ignoreUrls.contains(element))) {
try { try {
var source = getSource(url); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults)); apps.add(await getApp(
source, url, source.additionalSourceAppSpecificDefaults));
} catch (e) { } catch (e) {
errors.addAll(<String, dynamic>{url: e}); errors.addAll(<String, dynamic>{url: e});
} }
} }
return [apps, errors]; return [apps, errors];
} }
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
} }

View File

@ -21,7 +21,7 @@ packages:
name: archive name: archive
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.3.4" version: "3.3.5"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -187,7 +187,7 @@ packages:
name: flutter_launcher_icons name: flutter_launcher_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.10.0" version: "0.11.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -201,7 +201,7 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "12.0.3" version: "12.0.4"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@ -372,7 +372,7 @@ packages:
name: path_provider_android name: path_provider_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.21" version: "2.0.22"
path_provider_ios: path_provider_ios:
dependency: transitive dependency: transitive
description: description:
@ -491,7 +491,7 @@ packages:
name: share_plus name: share_plus
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.2.0" version: "6.3.0"
share_plus_platform_interface: share_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -643,14 +643,14 @@ packages:
name: url_launcher name: url_launcher
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.6" version: "6.1.7"
url_launcher_android: url_launcher_android:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.21" version: "6.0.22"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -699,7 +699,7 @@ packages:
name: uuid name: uuid
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.6" version: "3.0.7"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -741,7 +741,7 @@ packages:
name: win32 name: win32
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.1" version: "3.1.2"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -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.7.4+60 # When changing this, update the tag in main() accordingly version: 0.8.0+63 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.18.2 <3.0.0' sdk: '>=2.18.2 <3.0.0'
@ -62,7 +62,7 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is