Compare commits

...

26 Commits

Author SHA1 Message Date
18198bbdfe Tiny bugfix in default source-specific options 2022-09-24 02:39:04 -04:00
cf3c86abb8 Updated version 2022-09-24 02:16:58 -04:00
570e376742 Tiny UI tweak 2022-09-24 02:15:08 -04:00
32ae5e8175 Added error reporting on forgeround update check 2022-09-24 02:10:56 -04:00
cbf5057c17 Changed App tile layout 2022-09-24 02:08:21 -04:00
2cfe62142a Added Apps search 2022-09-24 01:57:45 -04:00
d03486fc5d Adds Source-specific options + other changes (#26)
* Started work on dynamic forms

* dynamic form progress (switch doesn't work)

* dynamic forms work

* Gen. form improvements, source specific data (untested)

* Gen form bugfix

* Removed redundant generated modal code

* Added custom validators to gen. forms

* Progress on source options (incomplete), gen form bugfixes

* Tweaks, more

* More

* Progress

* Changed a default

* Additional options done!
2022-09-24 00:36:32 -04:00
224e435bbb Moved App Sources into separate files 2022-09-22 19:35:15 -04:00
90fa0e06ce Fixed App webpage scrolling issue 2022-09-18 13:59:26 -04:00
6c1ad94b4f Fixed build number 2022-09-17 19:09:32 -04:00
7d7986f8bf FIxed a typo 2022-09-17 19:05:55 -04:00
3ddf9ea736 Fixed incorrect background colours 2022-09-17 18:57:14 -04:00
2272f8b4e6 Merge pull request #15 from ImranR98/ui-improvements
UI improvements
2022-09-17 18:42:05 -04:00
9514062a3a Updated version 2022-09-17 18:40:01 -04:00
da57018b90 Added "not installed" button 2022-09-17 18:39:11 -04:00
87e31c37aa 'Already Installed' button also takes 'Already Updated' 2022-09-17 18:11:00 -04:00
cb4dfff1b9 Added nav animation 2022-09-17 18:06:05 -04:00
911b06bfb6 Slight tweak to import/export buttons 2022-09-17 17:54:50 -04:00
53513bfdd1 Added sections to settings page 2022-09-17 17:19:58 -04:00
681092d895 Colour, alignment fixes 2022-09-17 17:00:08 -04:00
0f6b6253de Reduced haptic feedback (consequential actions only) 2022-09-17 16:48:42 -04:00
c724b276ab Added strechy appbars to all pages 2022-09-17 16:15:30 -04:00
35369273bd Changed source order, started adding strechy titlebars 2022-09-17 14:39:38 -04:00
0b1863a227 Update README.md 2022-09-17 02:34:14 -04:00
9e21f2d6e6 Updated version 2022-09-17 02:16:11 -04:00
6f11f850e0 Import now uses file picker 2022-09-17 02:12:17 -04:00
23 changed files with 1791 additions and 969 deletions

View File

@ -10,6 +10,7 @@ Currently supported App sources:
- [GitHub](https://github.com/) - [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/) - [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/) - [F-Droid](https://f-droid.org/)
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/) - [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/) - [Signal](https://signal.org/)

View File

@ -0,0 +1,57 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var latestReleaseDiv =
parse(res.body).querySelector('#latest.package-version');
var apkUrl = latestReleaseDiv
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw noAPKFound;
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
}

123
lib/app_sources/github.dart Normal file
View File

@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHub implements AppSource {
@override
late String host = 'github.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
var includePrereleases =
additionalData.isNotEmpty && additionalData[0] == "true";
var fallbackToOlderReleases =
additionalData.length >= 2 && additionalData[1] == "true";
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
? additionalData[2]
: null;
Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
List<String> getReleaseAPKUrls(dynamic release) =>
(release['assets'] as List<dynamic>?)
?.map((e) {
return e['browser_download_url'] != null
? e['browser_download_url'] as String
: '';
})
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList() ??
[];
dynamic targetRelease;
for (int i = 0; i < releases.length; i++) {
if (!fallbackToOlderReleases && i > 0) break;
if (!includePrereleases && releases[i]['prerelease'] == true) {
continue;
}
if (regexFilter != null &&
!RegExp(regexFilter)
.hasMatch((releases[i]['name'] as String).trim())) {
continue;
}
var apkUrls = getReleaseAPKUrls(releases[i]);
if (apkUrls.isEmpty) {
continue;
}
targetRelease = releases[i];
targetRelease['apkUrls'] = apkUrls;
break;
}
if (targetRelease == null) {
throw couldNotFindReleases;
}
if ((targetRelease['apkUrls'] as List<String>).isEmpty) {
throw noAPKFound;
}
String? version = targetRelease['tag_name'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, targetRelease['apkUrls']);
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [
[GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)],
[
GeneratedFormItem(
label: "Fallback to older releases", type: FormItemType.bool)
],
[
GeneratedFormItem(
label: "Filter Release Titles by Regular Expression",
type: FormItemType.string,
required: false,
additionalValidators: [
(value) {
if (value == null || value.isEmpty) {
return null;
}
try {
RegExp(value);
} catch (e) {
return "Invalid regular expression";
}
return null;
}
])
]
];
@override
List<String> additionalDataDefaults = ["true", "true", ""];
}

View File

@ -0,0 +1,71 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitLab implements AppSource {
@override
late String host = 'gitlab.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin),
// GitLab releases may contain links to externally hosted APKs
...getLinksFromParsedHTML(entryContent,
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
.where((element) => Uri.parse(element).host != '')
.toList()
];
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
}

View File

@ -0,0 +1,65 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class IzzyOnDroid implements AppSource {
@override
late String host = 'android.izzysoft.de';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var multipleVersionApkUrls = parsedHtml
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
}

View File

@ -0,0 +1,51 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
}

View File

@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class Signal implements AppSource {
@override
late String host = 'signal.org';
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw noAPKFound;
}
String? version = json['versionName'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class CustomAppBar extends StatefulWidget {
const CustomAppBar({super.key, required this.title});
final String title;
@override
State<CustomAppBar> createState() => _CustomAppBarState();
}
class _CustomAppBarState extends State<CustomAppBar> {
@override
Widget build(BuildContext context) {
return SliverAppBar(
pinned: true,
automaticallyImplyLeading: false,
expandedHeight: 100,
flexibleSpace: FlexibleSpaceBar(
titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
title: Text(
widget.title,
style:
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color),
),
),
);
}
}

View File

@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
enum FormItemType { string, bool }
typedef OnValueChanges = void Function(List<String> values, bool valid);
class GeneratedFormItem {
late String label;
late FormItemType type;
late bool required;
late int max;
late List<String? Function(String? value)> additionalValidators;
GeneratedFormItem(
{this.label = "Input",
this.type = FormItemType.string,
this.required = true,
this.max = 1,
this.additionalValidators = const []});
}
class GeneratedForm extends StatefulWidget {
const GeneratedForm(
{super.key,
required this.items,
required this.onValueChanges,
required this.defaultValues});
final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges;
final List<String> defaultValues;
@override
State<GeneratedForm> createState() => _GeneratedFormState();
}
class _GeneratedFormState extends State<GeneratedForm> {
final _formKey = GlobalKey<FormState>();
late List<List<String>> values;
late List<List<Widget>> formInputs;
List<List<Widget>> rows = [];
// If any value changes, call this to update the parent with value and validity
void someValueChanged() {
List<String> returnValues = [];
var valid = true;
for (int r = 0; r < values.length; r++) {
for (int i = 0; i < values[r].length; i++) {
returnValues.add(values[r][i]);
if (formInputs[r][i] is TextFormField) {
valid = valid &&
((formInputs[r][i].key as GlobalKey<FormFieldState>)
.currentState
?.isValid ??
false);
}
}
}
widget.onValueChanges(returnValues, valid);
}
@override
void initState() {
super.initState();
// Initialize form values as all empty
int j = 0;
values = widget.items
.map((row) => row.map((e) {
return j < widget.defaultValues.length
? widget.defaultValues[j++]
: "";
}).toList())
.toList();
// Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) {
if (e.value.type == FormItemType.string) {
final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField(
key: formFieldKey,
initialValue: values[row.key][e.key],
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (value) {
setState(() {
values[row.key][e.key] = value;
someValueChanged();
});
},
decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? " *" : "")),
minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) {
if (e.value.required && (value == null || value.trim().isEmpty)) {
return '${e.value.label} (required)';
}
for (var validator in e.value.additionalValidators) {
String? result = validator(value);
if (result != null) {
return result;
}
}
return null;
},
);
} else {
return Container(); // Some input types added in build
}
}).toList();
}).toList();
}
@override
Widget build(BuildContext context) {
for (var r = 0; r < formInputs.length; r++) {
for (var e = 0; e < formInputs[r].length; e++) {
if (widget.items[r][e].type == FormItemType.bool) {
formInputs[r][e] = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.items[r][e].label),
Switch(
value: values[r][e] == "true",
onChanged: (value) {
setState(() {
values[r][e] = value ? "true" : "";
someValueChanged();
});
})
],
);
}
}
}
rows.clear();
formInputs.asMap().entries.forEach((rowInputs) {
if (rowInputs.key > 0) {
rows.add([
SizedBox(
height: widget.items[rowInputs.key][0].type == FormItemType.bool &&
widget.items[rowInputs.key - 1][0].type ==
FormItemType.string
? 25
: 8,
)
]);
}
List<Widget> rowItems = [];
rowInputs.value.asMap().entries.forEach((rowInput) {
if (rowInput.key > 0) {
rowItems.add(const SizedBox(
width: 20,
));
}
rowItems.add(Expanded(child: rowInput.value));
});
rows.add(rowItems);
});
return Form(
key: _formKey,
child: Column(
children: [
...rows.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [...row.map((e) => e)],
))
],
));
}
}

View File

@ -1,81 +1,57 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormItem {
late String message;
late bool required;
late int lines;
GeneratedFormItem(this.message, this.required, this.lines);
}
class GeneratedFormModal extends StatefulWidget { class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal( const GeneratedFormModal(
{super.key, required this.title, required this.items}); {super.key,
required this.title,
required this.items,
required this.defaultValues});
final String title; final String title;
final List<GeneratedFormItem> items; final List<List<GeneratedFormItem>> items;
final List<String> defaultValues;
@override @override
State<GeneratedFormModal> createState() => _GeneratedFormModalState(); State<GeneratedFormModal> createState() => _GeneratedFormModalState();
} }
class _GeneratedFormModalState extends State<GeneratedFormModal> { class _GeneratedFormModalState extends State<GeneratedFormModal> {
final _formKey = GlobalKey<FormState>(); List<String> values = [];
bool valid = false;
final urlInputController = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final formInputs = widget.items.map((e) {
final controller = TextEditingController();
return [
controller,
TextFormField(
decoration: InputDecoration(helperText: e.message),
controller: controller,
minLines: e.lines <= 1 ? null : e.lines,
maxLines: e.lines <= 1 ? 1 : e.lines,
validator: e.required
? (value) {
if (value == null || value.isEmpty) {
return '${e.message} (required)';
}
return null;
}
: null,
)
];
}).toList();
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: Text(widget.title), title: Text(widget.title),
content: Form( content: GeneratedForm(
key: _formKey, items: widget.items,
child: Column( onValueChanges: (values, valid) {
crossAxisAlignment: CrossAxisAlignment.stretch, setState(() {
children: [...formInputs.map((e) => e[1] as Widget)], this.values = values;
)), this.valid = valid;
});
},
defaultValues: widget.defaultValues),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: !valid
if (_formKey.currentState?.validate() == true) { ? null
HapticFeedback.heavyImpact(); : () {
Navigator.of(context).pop(formInputs if (valid) {
.map((e) => (e[0] as TextEditingController).value.text) HapticFeedback.selectionClick();
.toList()); Navigator.of(context).pop(values);
} }
}, },
child: const Text('Continue')) child: const Text('Continue'))
], ],
); );
} }
} }
// TODO: Add support for larger textarea so this can be used for text/json imports

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/pages/home.dart'; import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart';
@ -12,7 +13,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';
const String currentReleaseTag = const String currentReleaseTag =
'v0.2.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v0.3.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point') @pragma('vm:entry-point')
void bgTaskCallback() { void bgTaskCallback() {
@ -58,7 +59,7 @@ void main() async {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => AppsProvider( create: (context) => AppsProvider(
shouldLoadApps: true, shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: true, shouldCheckUpdatesAfterLoad: false,
shouldDeleteAPKs: true)), shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider()) Provider(create: (context) => NotificationsProvider())
@ -102,7 +103,8 @@ class MyApp extends StatelessWidget {
currentReleaseTag, currentReleaseTag,
currentReleaseTag, currentReleaseTag,
[], [],
0)); 0,
["true"]));
} }
} }

View File

@ -0,0 +1,32 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:obtainium/providers/source_provider.dart';
class GitHubStars implements MassAppSource {
@override
late String name = 'GitHub Starred Repos';
@override
late List<String> requiredArgs = ['Username'];
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
}
Response res =
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String)
.toList();
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw 'Unable to find user\'s starred repos';
}
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -15,120 +17,187 @@ class AddAppPage extends StatefulWidget {
} }
class _AddAppPageState extends State<AddAppPage> { class _AddAppPageState extends State<AddAppPage> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
bool gettingAppInfo = false; bool gettingAppInfo = false;
String userInput = "";
AppSource? pickedSource;
List<String> additionalData = [];
bool validAdditionalData = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
return Center( return Scaffold(
child: Form( backgroundColor: Theme.of(context).colorScheme.surface,
key: _formKey, body: CustomScrollView(slivers: <Widget>[
child: Column( const CustomAppBar(title: 'Add App'),
mainAxisAlignment: MainAxisAlignment.spaceBetween, SliverFillRemaining(
crossAxisAlignment: CrossAxisAlignment.stretch, child: Padding(
children: [
Container(),
Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextFormField( Row(
decoration: const InputDecoration( children: [
hintText: 'https://github.com/Author/Project', Expanded(
helperText: 'Enter the App source URL'), child: GeneratedForm(
controller: urlInputController, items: [
validator: (value) { [
if (value == null || GeneratedFormItem(
value.isEmpty || label: "App Source Url",
Uri.tryParse(value) == null) { additionalValidators: [
return 'Please enter a supported source URL'; (value) {
} try {
return null; sourceProvider
}, .getSource(value ?? "")
), .standardizeURL(
Padding( makeUrlHttps(
padding: const EdgeInsets.symmetric(vertical: 16.0), value ?? ""));
child: ElevatedButton( } catch (e) {
onPressed: gettingAppInfo return e is String
? null ? e
: () { : "Error";
HapticFeedback.mediumImpact(); }
if (_formKey.currentState!.validate()) { return null;
setState(() { }
gettingAppInfo = true; ])
}); ]
sourceProvider ],
.getApp(urlInputController.value.text) onValueChanges: (values, valid) {
.then((app) {
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
if (appsProvider.apps.containsKey(app.id)) {
throw 'App already added';
}
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider.saveApp(app).then((_) {
urlInputController.clear();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: app.id)));
});
});
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() { setState(() {
gettingAppInfo = false; userInput = values[0];
var source = valid
? sourceProvider.getSource(userInput)
: null;
if (pickedSource != source) {
pickedSource = source;
additionalData = source != null
? source.additionalDataDefaults
: [];
validAdditionalData = source != null
? sourceProvider
.doesSourceHaveRequiredAdditionalData(
source)
: true;
}
}); });
}); },
} defaultValues: const [])),
}, const SizedBox(
child: const Text('Add'), width: 16,
),
ElevatedButton(
onPressed: gettingAppInfo ||
pickedSource == null ||
(pickedSource!.additionalDataFormItems
.isNotEmpty &&
!validAdditionalData)
? null
: () {
HapticFeedback.selectionClick();
setState(() {
gettingAppInfo = true;
});
sourceProvider
.getApp(pickedSource!, userInput,
additionalData)
.then((app) {
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
if (appsProvider.apps
.containsKey(app.id)) {
throw 'App already added';
}
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider.saveApp(app).then((_) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(
appId: app.id)));
});
});
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
},
child: const Text('Add'))
],
), ),
), if (pickedSource != null &&
], (pickedSource!.additionalDataFormItems.isNotEmpty))
), Column(
), crossAxisAlignment: CrossAxisAlignment.stretch,
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ children: [
const Text( const Divider(
'Supported Sources:', height: 64,
// style: TextStyle(fontWeight: FontWeight.bold), ),
// style: Theme.of(context).textTheme.bodySmall, Text(
), 'Additional Options for ${pickedSource?.runtimeType}',
const SizedBox( style: TextStyle(
height: 8, color:
), Theme.of(context).colorScheme.primary)),
...sourceProvider const SizedBox(
.getSourceHosts() height: 16,
.map((e) => GestureDetector( ),
onTap: () { GeneratedForm(
launchUrlString('https://$e', items: pickedSource!.additionalDataFormItems,
mode: LaunchMode.externalApplication); onValueChanges: (values, valid) {
}, setState(() {
child: Text( additionalData = values;
e, validAdditionalData = valid;
style: const TextStyle( });
decoration: TextDecoration.underline, },
fontStyle: FontStyle.italic), defaultValues:
))) pickedSource!.additionalDataDefaults)
.toList() ],
]), )
if (gettingAppInfo) else
const LinearProgressIndicator() Expanded(
else child: Column(
Container(), crossAxisAlignment: CrossAxisAlignment.center,
], mainAxisAlignment: MainAxisAlignment.center,
)), children: [
); // const SizedBox(
// height: 48,
// ),
const Text(
'Supported Sources:',
),
const SizedBox(
height: 8,
),
...sourceProvider
.getSourceHosts()
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://$e',
mode:
LaunchMode.externalApplication);
},
child: Text(
e,
style: const TextStyle(
decoration:
TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
])),
])),
)
]));
} }
} }

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -20,14 +22,15 @@ class _AppPageState extends State<AppPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (app?.app.installedVersion != null) { if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.app.id); appsProvider.getUpdate(app!.app.id);
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: settingsProvider.showAppWebpage ? AppBar() : null,
title: Text('${app?.app.author}/${app?.app.name}'), backgroundColor: Theme.of(context).colorScheme.surface,
),
body: settingsProvider.showAppWebpage body: settingsProvider.showAppWebpage
? WebView( ? WebView(
initialUrl: app?.app.url, initialUrl: app?.app.url,
@ -91,15 +94,15 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion == null) if (app?.app.installedVersion != app?.app.latestVersion)
IconButton( IconButton(
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: const Text( title: Text(
'App Already Installed?'), 'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
@ -108,6 +111,7 @@ class _AppPageState extends State<AppPage> {
child: const Text('No')), child: const Text('No')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app; var updatedApp = app?.app;
if (updatedApp != null) { if (updatedApp != null) {
updatedApp.installedVersion = updatedApp.installedVersion =
@ -124,9 +128,73 @@ class _AppPageState extends State<AppPage> {
}); });
}, },
tooltip: 'Mark as Installed', tooltip: 'Mark as Installed',
icon: const Icon(Icons.done)), icon: const Icon(Icons.done))
if (app?.app.installedVersion == null) else
const SizedBox(width: 16.0), IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('App Not Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
null;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Not Installed'))
],
);
});
},
tooltip: 'Mark as Not Installed',
icon: const Icon(Icons.no_cell_outlined)),
if (source != null &&
source.additionalDataFormItems.isNotEmpty)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source.additionalDataFormItems,
defaultValues:
source.additionalDataDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
sourceProvider
.getApp(source, changedApp.url,
changedApp.additionalData)
.then((finalChangedApp) {
appsProvider.saveApp(finalChangedApp);
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
}
});
},
icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
@ -154,7 +222,6 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () {
HapticFeedback.lightImpact();
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
@ -165,7 +232,8 @@ class _AppPageState extends State<AppPage> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback
.selectionClick();
appsProvider appsProvider
.removeApp(app!.app.id) .removeApp(app!.app.id)
.then((_) { .then((_) {
@ -178,7 +246,6 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove')), child: const Text('Remove')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Cancel')) child: const Text('Cancel'))

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -13,12 +16,47 @@ class AppsPage extends StatefulWidget {
} }
class _AppsPageState extends State<AppsPage> { class _AppsPageState extends State<AppsPage> {
AppsFilter? filter;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates(); var existingUpdateAppIds = appsProvider.getExistingUpdates();
var sortedApps = appsProvider.apps.values.toList(); var sortedApps = appsProvider.apps.values.toList();
if (filter != null) {
sortedApps = sortedApps.where((app) {
if (app.app.installedVersion == app.app.latestVersion &&
filter!.onlyNonLatest) {
return false;
}
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
return true;
}
List<String> nameTokens = filter!.nameFilter
.split(' ')
.where((element) => element.trim().isNotEmpty)
.toList();
List<String> authorTokens = filter!.authorFilter
.split(' ')
.where((element) => element.trim().isNotEmpty)
.toList();
for (var t in nameTokens) {
if (!app.app.name.toLowerCase().contains(t.toLowerCase())) {
return false;
}
}
for (var t in authorTokens) {
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
return false;
}
}
return true;
}).toList();
}
sortedApps.sort((a, b) { sortedApps.sort((a, b) {
int result = 0; int result = 0;
if (settingsProvider.sortColumn == SortColumnSettings.authorName) { if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
@ -30,66 +68,142 @@ class _AppsPageState extends State<AppsPage> {
} }
return result; return result;
}); });
if (settingsProvider.sortOrder == SortOrderSettings.ascending) { if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
sortedApps = sortedApps.reversed.toList(); sortedApps = sortedApps.reversed.toList();
} }
return Scaffold( return Scaffold(
floatingActionButton: existingUpdateAppIds.isEmpty backgroundColor: Theme.of(context).colorScheme.surface,
? null floatingActionButton:
: ElevatedButton.icon( Row(mainAxisAlignment: MainAxisAlignment.end, children: [
onPressed: appsProvider.areDownloadsRunning() existingUpdateAppIds.isEmpty || filter != null
? null ? const SizedBox()
: () { : ElevatedButton.icon(
HapticFeedback.heavyImpact(); onPressed: appsProvider.areDownloadsRunning()
settingsProvider.getInstallPermission().then((_) { ? null
appsProvider.downloadAndInstallLatestApp( : () {
existingUpdateAppIds, context); HapticFeedback.heavyImpact();
settingsProvider.getInstallPermission().then((_) {
appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context);
});
},
icon: const Icon(Icons.install_mobile_outlined),
label: const Text('Install All')),
const SizedBox(
width: 16,
),
appsProvider.apps.isEmpty
? const SizedBox()
: ElevatedButton.icon(
onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Filter Apps',
items: [
[
GeneratedFormItem(
label: "App Name", required: false),
GeneratedFormItem(
label: "Author", required: false)
],
[
GeneratedFormItem(
label: "Ignore Up-to-Date Apps",
type: FormItemType.bool)
]
],
defaultValues: filter == null
? []
: [
filter!.nameFilter,
filter!.authorFilter,
filter!.onlyNonLatest ? 'true' : ''
]);
}).then((values) {
if (values != null &&
values
.where((element) => element.isNotEmpty)
.isNotEmpty) {
setState(() {
filter = AppsFilter(
nameFilter: values[0],
authorFilter: values[1],
onlyNonLatest: values[2] == "true");
}); });
}, } else {
icon: const Icon(Icons.update), setState(() {
label: const Text('Update All')), filter = null;
body: Center( });
child: appsProvider.loadingApps }
? const CircularProgressIndicator() });
: appsProvider.apps.isEmpty },
? Text( label: Text(filter == null ? 'Search' : 'Modify Search'),
'No Apps', icon: Icon(
style: Theme.of(context).textTheme.headlineMedium, filter == null ? Icons.search : Icons.manage_search)),
) ]),
: RefreshIndicator( body: RefreshIndicator(
onRefresh: () { onRefresh: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
return appsProvider.checkUpdates(); return appsProvider.checkUpdates().catchError((e) {
}, ScaffoldMessenger.of(context).showSnackBar(
child: ListView( SnackBar(content: Text(e.toString())),
children: sortedApps );
.map( });
(e) => ListTile( },
title: Text('${e.app.author}/${e.app.name}'), child: CustomScrollView(slivers: <Widget>[
subtitle: Text( const CustomAppBar(title: 'Apps'),
e.app.installedVersion ?? 'Not Installed'), if (appsProvider.loadingApps || sortedApps.isEmpty)
trailing: e.downloadProgress != null SliverFillRemaining(
? Text( child: Center(
'Downloading - ${e.downloadProgress?.toInt()}%') child: appsProvider.loadingApps
: (e.app.installedVersion != null && ? const CircularProgressIndicator()
e.app.installedVersion != : Text(
e.app.latestVersion appsProvider.apps.isEmpty
? const Text('Update Available') ? 'No Apps'
: null), : 'No Search Results',
onTap: () { style:
Navigator.push( Theme.of(context).textTheme.headlineMedium,
context, ))),
MaterialPageRoute( SliverList(
builder: (context) => delegate: SliverChildBuilderDelegate(
AppPage(appId: e.app.id)), (BuildContext context, int index) {
); return ListTile(
}, title: Text(sortedApps[index].app.name),
), subtitle: Text('By ${sortedApps[index].app.author}'),
) trailing: sortedApps[index].downloadProgress != null
.toList(), ? Text(
), 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
), : (sortedApps[index].app.installedVersion != null &&
)); sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion
? const Text('Update Available')
: Text(sortedApps[index].app.installedVersion ??
'Not Installed')),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: sortedApps[index].app.id)),
);
},
);
}, childCount: sortedApps.length))
])));
} }
} }
class AppsFilter {
late String nameFilter;
late String authorFilter;
late bool onlyNonLatest;
AppsFilter(
{this.nameFilter = "",
this.authorFilter = "",
this.onlyNonLatest = false});
}

View File

@ -1,3 +1,4 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/add_app.dart';
@ -12,33 +13,56 @@ class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState(); State<HomePage> createState() => _HomePageState();
} }
class NavigationPageItem {
late String title;
late IconData icon;
late Widget widget;
NavigationPageItem(this.title, this.icon, this.widget);
}
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = []; List<int> selectedIndexHistory = [];
List<Widget> pages = [
const AppsPage(), List<NavigationPageItem> pages = [
const AddAppPage(), NavigationPageItem('Apps', Icons.apps, const AppsPage()),
const ImportExportPage(), NavigationPageItem('Add App', Icons.add, const AddAppPage()),
const SettingsPage() NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(
child: Scaffold( child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')), backgroundColor: Theme.of(context).colorScheme.surface,
body: pages.elementAt( body: PageTransitionSwitcher(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last), transitionBuilder: (
Widget child,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last)
.widget,
),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
destinations: const [ destinations: pages
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), .map((e) =>
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), NavigationDestination(icon: Icon(e.icon), label: e.title))
NavigationDestination( .toList(),
icon: Icon(Icons.import_export), label: 'Import/Export'),
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
],
onDestinationSelected: (int index) { onDestinationSelected: (int index) {
HapticFeedback.lightImpact(); HapticFeedback.selectionClick();
setState(() { setState(() {
if (index == 0) { if (index == 0) {
selectedIndexHistory.clear(); selectedIndexHistory.clear();

View File

@ -1,12 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
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/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';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
class ImportExportPage extends StatefulWidget { class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key}); const ImportExportPage({super.key});
@ -16,13 +20,23 @@ class ImportExportPage extends StatefulWidget {
} }
class _ImportExportPageState extends State<ImportExportPage> { class _ImportExportPageState extends State<ImportExportPage> {
bool gettingAppInfo = false; bool importInProgress = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>(); var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>(); var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
side: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
Future<List<List<String>>> addApps(List<String> urls) async { Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission(); await settingsProvider.getInstallPermission();
@ -41,190 +55,270 @@ class _ImportExportPageState extends State<ImportExportPage> {
return errors; return errors;
} }
return Padding( return Scaffold(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), backgroundColor: Theme.of(context).colorScheme.surface,
child: Column( body: CustomScrollView(slivers: <Widget>[
crossAxisAlignment: CrossAxisAlignment.stretch, const CustomAppBar(title: 'Import/Export'),
children: [ SliverFillRemaining(
ElevatedButton( hasScrollBody: false,
onPressed: appsProvider.apps.isEmpty || gettingAppInfo child: Padding(
? null padding:
: () { const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
HapticFeedback.lightImpact(); child: Column(
appsProvider.exportApps().then((String path) { crossAxisAlignment: CrossAxisAlignment.stretch,
ScaffoldMessenger.of(context).showSnackBar( children: [
SnackBar(content: Text('Exported to $path')), Row(
);
});
},
child: const Text('Obtainium Export')),
const SizedBox(
height: 8,
),
ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Obtainium Import',
items: [
GeneratedFormItem(
'Obtainium Export JSON Data', true, 7)
]);
}).then((values) {
if (values != null) {
try {
jsonDecode(values[0]);
} catch (e) {
throw 'Invalid input';
}
appsProvider.importApps(values[0]).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
},
child: const Text('Obtainium Import')),
if (gettingAppInfo)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: gettingAppInfo
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import from URL List',
items: [
GeneratedFormItem('App URL List', true, 7)
],
);
}).then((values) {
if (values != null) {
var urls = (values[0] as String).split('\n');
setState(() {
gettingAppInfo = true;
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
});
});
}
});
},
child: const Text('Import from URL List')),
...sourceProvider.massSources
.map((source) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 8), Expanded(
TextButton( child: TextButton(
onPressed: gettingAppInfo style: outlineButtonStyle,
? null onPressed: appsProvider.apps.isEmpty ||
: () { importInProgress
showDialog( ? null
context: context, : () {
builder: (BuildContext ctx) { HapticFeedback.selectionClick();
return GeneratedFormModal( appsProvider
title: 'Import ${source.name}', .exportApps()
items: source.requiredArgs .then((String path) {
.map((e) => ScaffoldMessenger.of(context)
GeneratedFormItem( .showSnackBar(
e, true, 1)) SnackBar(
.toList()); content: Text(
}).then((values) { 'Exported to $path')),
if (values != null) { );
source.getUrls(values).then((urls) { });
},
child: const Text('Obtainium Export'))),
const SizedBox(
width: 16,
),
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() { setState(() {
gettingAppInfo = true; importInProgress = true;
}); });
addApps(urls).then((errors) { if (result != null) {
if (errors.isEmpty) { String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw 'Invalid input';
}
appsProvider
.importApps(data)
.then((value) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar( .showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Imported ${urls.length} Apps')), '$value App${value == 1 ? '' : 's'} Imported')),
); );
} else {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return ImportErrorDialog(
urlsLength:
urls.length,
errors: errors);
});
}
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
}); });
}); } else {
// User canceled the picker
}
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar( .showSnackBar(
SnackBar( SnackBar(
content: Text(e.toString())), content: Text(e.toString())),
); );
}).whenComplete(() {
setState(() {
importInProgress = false;
});
}); });
} },
child: const Text('Obtainium Import')))
],
),
if (importInProgress)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import from URL List',
items: [
[
GeneratedFormItem(
label: 'App URL List',
max: 7,
additionalValidators: [
(String? value) {
if (value != null &&
value.isNotEmpty) {
var lines = value
.trim()
.split('\n');
for (int i = 0;
i < lines.length;
i++) {
try {
sourceProvider
.getSource(
lines[i]);
} catch (e) {
return 'Line ${i + 1}: $e';
}
}
}
return null;
}
])
]
],
defaultValues: const [],
);
}).then((values) {
if (values != null) {
var urls =
(values[0] as String).split('\n');
setState(() {
importInProgress = true;
}); });
}, addApps(urls).then((errors) {
child: Text('Import ${source.name}')) if (errors.isEmpty) {
])) ScaffoldMessenger.of(context)
.toList() .showSnackBar(
], SnackBar(
)); content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
});
},
child: const Text(
'Import from URL List',
)),
...sourceProvider.massSources
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
items: source
.requiredArgs
.map((e) => [
GeneratedFormItem(
label: e)
])
.toList(),
defaultValues: const [],
);
}).then((values) {
if (values != null) {
source
.getUrls(values)
.then((urls) {
setState(() {
importInProgress = true;
});
addApps(urls)
.then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength: urls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
}).catchError((e) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
e.toString())),
);
});
}
});
},
child: Text('Import ${source.name}'))
]))
.toList()
],
)))
]));
} }
} }
@ -274,7 +368,6 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Okay')) child: const Text('Okay'))

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -18,185 +18,219 @@ class _SettingsPageState extends State<SettingsPage> {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
return Padding( return Scaffold(
padding: const EdgeInsets.all(16), backgroundColor: Theme.of(context).colorScheme.surface,
child: settingsProvider.prefs == null body: CustomScrollView(slivers: <Widget>[
? Container() const CustomAppBar(title: 'Settings'),
: Column( SliverFillRemaining(
children: [ hasScrollBody: true,
DropdownButtonFormField( child: Padding(
decoration: const InputDecoration(labelText: 'Theme'), padding: const EdgeInsets.all(16),
value: settingsProvider.theme, child: settingsProvider.prefs == null
items: const [ ? const SizedBox()
DropdownMenuItem( : Column(
value: ThemeSettings.dark, crossAxisAlignment: CrossAxisAlignment.start,
child: Text('Dark'), children: [
), Text(
DropdownMenuItem( 'Appearance',
value: ThemeSettings.light, style: TextStyle(
child: Text('Light'), color: Theme.of(context).colorScheme.primary),
), ),
DropdownMenuItem( DropdownButtonFormField(
value: ThemeSettings.system, decoration:
child: Text('Follow System'), const InputDecoration(labelText: 'Theme'),
) value: settingsProvider.theme,
], items: const [
onChanged: (value) { DropdownMenuItem(
if (value != null) { value: ThemeSettings.dark,
settingsProvider.theme = value; child: Text('Dark'),
} ),
}), DropdownMenuItem(
const SizedBox( value: ThemeSettings.light,
height: 16, child: Text('Light'),
), ),
DropdownButtonFormField( DropdownMenuItem(
decoration: const InputDecoration(labelText: 'Colour'), value: ThemeSettings.system,
value: settingsProvider.colour, child: Text('Follow System'),
items: const [ )
DropdownMenuItem( ],
value: ColourSettings.basic, onChanged: (value) {
child: Text('Obtainium'), if (value != null) {
), settingsProvider.theme = value;
DropdownMenuItem( }
value: ColourSettings.materialYou, }),
child: Text('Material You'), const SizedBox(
) height: 16,
], ),
onChanged: (value) { DropdownButtonFormField(
if (value != null) { decoration:
settingsProvider.colour = value; const InputDecoration(labelText: 'Colour'),
} value: settingsProvider.colour,
}), items: const [
const SizedBox( DropdownMenuItem(
height: 16, value: ColourSettings.basic,
), child: Text('Obtainium'),
DropdownButtonFormField( ),
decoration: const InputDecoration( DropdownMenuItem(
labelText: 'Background Update Checking Interval'), value: ColourSettings.materialYou,
value: settingsProvider.updateInterval, child: Text('Material You'),
items: const [ )
DropdownMenuItem( ],
value: 15, onChanged: (value) {
child: Text('15 Minutes'), if (value != null) {
), settingsProvider.colour = value;
DropdownMenuItem( }
value: 30, }),
child: Text('30 Minutes'), const SizedBox(
), height: 16,
DropdownMenuItem( ),
value: 60, Row(
child: Text('1 Hour'), mainAxisAlignment: MainAxisAlignment.start,
), crossAxisAlignment: CrossAxisAlignment.start,
DropdownMenuItem( children: [
value: 360, Expanded(
child: Text('6 Hours'), child: DropdownButtonFormField(
), decoration: const InputDecoration(
DropdownMenuItem( labelText: 'App Sort By'),
value: 720, value: settingsProvider.sortColumn,
child: Text('12 Hours'), items: const [
), DropdownMenuItem(
DropdownMenuItem( value:
value: 1440, SortColumnSettings.authorName,
child: Text('1 Day'), child: Text('Author/Name'),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 0, value:
child: Text('Never - Manual Only'), SortColumnSettings.nameAuthor,
), child: Text('Name/Author'),
], ),
onChanged: (value) { DropdownMenuItem(
if (value != null) { value: SortColumnSettings.added,
settingsProvider.updateInterval = value; child: Text('As Added'),
} )
}), ],
const SizedBox( onChanged: (value) {
height: 16, if (value != null) {
), settingsProvider.sortColumn = value;
DropdownButtonFormField( }
decoration: })),
const InputDecoration(labelText: 'App Sort By'), const SizedBox(
value: settingsProvider.sortColumn, width: 16,
items: const [ ),
DropdownMenuItem( Expanded(
value: SortColumnSettings.authorName, child: DropdownButtonFormField(
child: Text('Author/Name'), decoration: const InputDecoration(
), labelText: 'App Sort Order'),
DropdownMenuItem( value: settingsProvider.sortOrder,
value: SortColumnSettings.nameAuthor, items: const [
child: Text('Name/Author'), DropdownMenuItem(
), value: SortOrderSettings.ascending,
DropdownMenuItem( child: Text('Ascending'),
value: SortColumnSettings.added, ),
child: Text('As Added'), DropdownMenuItem(
) value: SortOrderSettings.descending,
], child: Text('Descending'),
onChanged: (value) { ),
if (value != null) { ],
settingsProvider.sortColumn = value; onChanged: (value) {
} if (value != null) {
}), settingsProvider.sortOrder = value;
const SizedBox( }
height: 16, })),
), ],
DropdownButtonFormField( ),
decoration: const SizedBox(
const InputDecoration(labelText: 'App Sort Order'), height: 16,
value: settingsProvider.sortOrder, ),
items: const [ Row(
DropdownMenuItem( mainAxisAlignment: MainAxisAlignment.spaceBetween,
value: SortOrderSettings.ascending, children: [
child: Text('Ascending'), const Text('Show Source Webpage in App View'),
), Switch(
DropdownMenuItem( value: settingsProvider.showAppWebpage,
value: SortOrderSettings.descending, onChanged: (value) {
child: Text('Descending'), settingsProvider.showAppWebpage = value;
), })
], ],
onChanged: (value) { ),
if (value != null) { const Divider(
settingsProvider.sortOrder = value; height: 16,
} ),
}), const SizedBox(
const SizedBox( height: 16,
height: 16, ),
), Text(
Row( 'More',
mainAxisAlignment: MainAxisAlignment.spaceBetween, style: TextStyle(
children: [ color: Theme.of(context).colorScheme.primary),
const Text('Show Source Webpage in App View'), ),
Switch( DropdownButtonFormField(
value: settingsProvider.showAppWebpage, decoration: const InputDecoration(
onChanged: (value) { labelText:
settingsProvider.showAppWebpage = value; 'Background Update Checking Interval'),
}) value: settingsProvider.updateInterval,
], items: const [
), DropdownMenuItem(
const Spacer(), value: 15,
Row( child: Text('15 Minutes'),
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ DropdownMenuItem(
TextButton.icon( value: 30,
style: ButtonStyle( child: Text('30 Minutes'),
foregroundColor: ),
MaterialStateProperty.resolveWith<Color>( DropdownMenuItem(
(Set<MaterialState> states) { value: 60,
return Colors.grey; child: Text('1 Hour'),
}), ),
), DropdownMenuItem(
onPressed: () { value: 360,
HapticFeedback.lightImpact(); child: Text('6 Hours'),
launchUrlString(settingsProvider.sourceUrl, ),
mode: LaunchMode.externalApplication); DropdownMenuItem(
}, value: 720,
icon: const Icon(Icons.code), child: Text('12 Hours'),
label: Text( ),
'Source', DropdownMenuItem(
style: Theme.of(context).textTheme.bodySmall, value: 1440,
), child: Text('1 Day'),
) ),
], DropdownMenuItem(
), value: 0,
], child: Text('Never - Manual Only'),
)); ),
],
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.resolveWith<
Color>((Set<MaterialState> states) {
return Colors.grey;
}),
),
onPressed: () {
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
'Source',
style:
Theme.of(context).textTheme.bodySmall,
),
)
],
),
],
)))
]));
} }
} }

53
lib/pages/test_page.dart Normal file
View File

@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:obtainium/components/generated_form.dart';
class TestPage extends StatefulWidget {
const TestPage({super.key});
@override
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> {
List<String?>? sourceSpecificData;
bool valid = false;
List<List<GeneratedFormItem>> sourceSpecificInputs = [
[GeneratedFormItem(label: 'Test Item 1')],
[
GeneratedFormItem(label: 'Test Item 2', required: false),
GeneratedFormItem(label: 'Test Item 3')
],
[GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)]
];
List<String> defaultInputValues = ["ABC"];
void onSourceSpecificDataChanges(
List<String?> valuesFromForm, bool formValid) {
setState(() {
sourceSpecificData = valuesFromForm;
valid = formValid;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Test Page')),
backgroundColor: Theme.of(context).colorScheme.surface,
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: [
GeneratedForm(
items: sourceSpecificInputs,
onValueChanges: onSourceSpecificDataChanges,
defaultValues: defaultInputValues,
),
...(sourceSpecificData != null
? (sourceSpecificData as List<String?>)
.map((e) => Text(e ?? ""))
: [Container()])
])));
}
}

View File

@ -228,7 +228,11 @@ class AppsProvider with ChangeNotifier {
Future<App?> getUpdate(String appId) async { Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]!.app; App? currentApp = apps[appId]!.app;
App newApp = await SourceProvider().getApp(currentApp.url); SourceProvider sourceProvider = SourceProvider();
App newApp = await sourceProvider.getApp(
sourceProvider.getSource(currentApp.url),
currentApp.url,
currentApp.additionalData);
if (newApp.latestVersion != currentApp.latestVersion) { if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion; newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
@ -339,13 +343,12 @@ class _APKPickerState extends State<APKPicker> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(apkUrl); Navigator.of(context).pop(apkUrl);
}, },
child: const Text('Continue')) child: const Text('Continue'))
@ -376,13 +379,12 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
child: const Text('Continue')) child: const Text('Continue'))

View File

@ -4,8 +4,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.dart'; import 'package:obtainium/app_sources/fdroid.dart';
import 'package:html/parser.dart'; import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/app_sources/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
class AppNames { class AppNames {
late String author; late String author;
@ -30,8 +36,17 @@ class App {
late String latestVersion; late String latestVersion;
List<String> apkUrls = []; List<String> apkUrls = [];
late int preferredApkIndex; late int preferredApkIndex;
App(this.id, this.url, this.author, this.name, this.installedVersion, late List<String> additionalData;
this.latestVersion, this.apkUrls, this.preferredApkIndex); App(
this.id,
this.url,
this.author,
this.name,
this.installedVersion,
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalData);
@override @override
String toString() { String toString() {
@ -39,19 +54,21 @@ class App {
} }
factory App.fromJson(Map<String, dynamic> json) => App( factory App.fromJson(Map<String, dynamic> json) => App(
json['id'] as String, json['id'] as String,
json['url'] as String, json['url'] as String,
json['author'] as String, json['author'] as String,
json['name'] as String, json['name'] as String,
json['installedVersion'] == null json['installedVersion'] == null
? null ? null
: json['installedVersion'] as String, : json['installedVersion'] as String,
json['latestVersion'] as String, json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls'])), json['apkUrls'] == null
json['preferredApkIndex'] == null ? []
? 0 : List<String>.from(jsonDecode(json['apkUrls'])),
: json['preferredApkIndex'] as int, json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
); json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults
: List<String>.from(jsonDecode(json['additionalData'])));
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -61,7 +78,8 @@ class App {
'installedVersion': installedVersion, 'installedVersion': installedVersion,
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex 'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData)
}; };
} }
@ -71,10 +89,24 @@ escapeRegEx(String s) {
}); });
} }
const String couldNotFindReleases = 'Unable to fetch release info'; makeUrlHttps(String url) {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
return url;
}
const String couldNotFindReleases = 'Could not find a suitable release';
const String couldNotFindLatestVersion = const String couldNotFindLatestVersion =
'Could not determine latest release version'; 'Could not determine latest release version';
const String notValidURL = 'Not a valid URL'; String notValidURL(String sourceName) {
return 'Not a valid $sourceName App URL';
}
const String noAPKFound = 'No APK found'; const String noAPKFound = 'No APK found';
List<String> getLinksFromParsedHTML( List<String> getLinksFromParsedHTML(
@ -91,323 +123,35 @@ List<String> getLinksFromParsedHTML(
abstract class AppSource { abstract class AppSource {
late String host; late String host;
String standardizeURL(String url); String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl); Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData);
AppNames getAppNames(String standardUrl); AppNames getAppNames(String standardUrl);
late List<List<GeneratedFormItem>> additionalDataFormItems;
late List<String> additionalDataDefaults;
} }
class GitHub implements AppSource { abstract class MassAppSource {
@override late String name;
late String host = 'github.com'; late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) {
var releases = jsonDecode(res.body) as List<dynamic>;
// Right now, the latest non-prerelease version is picked
// If none exists, the latest prerelease version is picked
// In the future, the user could be given a choice
var nonPrereleaseReleases =
releases.where((element) => element['prerelease'] != true).toList();
var latestRelease = nonPrereleaseReleases.isNotEmpty
? nonPrereleaseReleases[0]
: releases.isNotEmpty
? releases[0]
: null;
if (latestRelease == null) {
throw couldNotFindReleases;
}
List<dynamic>? assets = latestRelease['assets'];
List<String>? apkUrlList = assets
?.map((e) {
return e['browser_download_url'] != null
? e['browser_download_url'] as String
: '';
})
.where((element) => element.toLowerCase().endsWith('.apk'))
.toList();
if (apkUrlList == null || apkUrlList.isEmpty) {
throw noAPKFound;
}
String? version = latestRelease['tag_name'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]);
}
}
class GitLab implements AppSource {
@override
late String host = 'gitlab.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = [
...getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin),
// GitLab releases may contain links to externally hosted APKs
...getLinksFromParsedHTML(entryContent,
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
.where((element) => Uri.parse(element).host != '')
.toList()
];
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
}
class Signal implements AppSource {
@override
late String host = 'signal.org';
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw noAPKFound;
}
String? version = json['versionName'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
}
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var latestReleaseDiv =
parse(res.body).querySelector('#latest.package-version');
var apkUrl = latestReleaseDiv
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw noAPKFound;
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
}
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
}
class IzzyOnDroid implements AppSource {
@override
late String host = 'android.izzysoft.de';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var multipleVersionApkUrls = parsedHtml
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
} }
class SourceProvider { class SourceProvider {
// Add more source classes here so they are available via the service
List<AppSource> sources = [ List<AppSource> sources = [
GitHub(), GitHub(),
GitLab(), GitLab(),
FDroid(), FDroid(),
IzzyOnDroid(),
Mullvad(), Mullvad(),
Signal(), Signal()
IzzyOnDroid()
]; ];
// Add more mass source classes here so they are available via the service
List<MassAppSource> massSources = [GitHubStars()]; List<MassAppSource> massSources = [GitHubStars()];
// Add more source classes here so they are available via the service
AppSource getSource(String url) { AppSource getSource(String url) {
url = makeUrlHttps(url);
AppSource? source; AppSource? source;
for (var s in sources) { for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) { if (url.toLowerCase().contains('://${s.host}')) {
@ -421,18 +165,23 @@ class SourceProvider {
return source; return source;
} }
Future<App> getApp(String url) async { bool doesSourceHaveRequiredAdditionalData(AppSource source) {
if (url.toLowerCase().indexOf('http://') != 0 && for (var row in source.additionalDataFormItems) {
url.toLowerCase().indexOf('https://') != 0) { for (var element in row) {
url = 'https://$url'; if (element.required) {
return true;
}
}
} }
if (url.toLowerCase().indexOf('https://www.') == 0) { return false;
url = 'https://${url.substring(12)}'; }
}
AppSource source = getSource(url); Future<App> getApp(
String standardUrl = source.standardizeURL(url); AppSource source, String url, List<String> additionalData) async {
String standardUrl = source.standardizeURL(makeUrlHttps(url));
AppNames names = source.getAppNames(standardUrl); AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl); APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalData);
return App( return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}', '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
standardUrl, standardUrl,
@ -441,7 +190,8 @@ class SourceProvider {
null, null,
apk.version, apk.version,
apk.apkUrls, apk.apkUrls,
apk.apkUrls.length - 1); apk.apkUrls.length - 1,
additionalData);
} }
/// Returns a length 2 list, where the first element is a list of Apps and /// Returns a length 2 list, where the first element is a list of Apps and
@ -451,7 +201,8 @@ class SourceProvider {
Map<String, dynamic> errors = {}; Map<String, dynamic> errors = {};
for (var url in urls) { for (var url in urls) {
try { try {
apps.add(await getApp(url)); var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults));
} catch (e) { } catch (e) {
errors.addAll(<String, dynamic>{url: e}); errors.addAll(<String, dynamic>{url: e});
} }
@ -461,37 +212,3 @@ class SourceProvider {
List<String> getSourceHosts() => sources.map((e) => e.host).toList(); List<String> getSourceHosts() => sources.map((e) => e.host).toList();
} }
abstract class MassAppSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
}
class GitHubStars implements MassAppSource {
@override
late String name = 'GitHub Starred Repos';
@override
late List<String> requiredArgs = ['Username'];
@override
Future<List<String>> getUrls(List<String> args) async {
if (args.length != requiredArgs.length) {
throw 'Wrong number of arguments provided';
}
Response res =
await get(Uri.parse('https://api.github.com/users/${args[0]}/starred'));
if (res.statusCode == 200) {
return (jsonDecode(res.body) as List<dynamic>)
.map((e) => e['html_url'] as String)
.toList();
} else {
if (res.headers['x-ratelimit-remaining'] == '0') {
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
}
throw 'Unable to find user\'s starred repos';
}
}
}

View File

@ -1,6 +1,13 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
animations:
dependency: "direct main"
description:
name: animations
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -162,6 +169,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.4" version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -194,21 +208,28 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "9.9.1" version: "10.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.1" version: "1.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

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.2.0+11 # When changing this, update the tag in main() accordingly version: 0.3.1+17 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.19.0-79.0.dev <3.0.0' sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -38,7 +38,7 @@ dependencies:
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.5
path_provider: ^2.0.11 path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^9.9.1 flutter_local_notifications: ^10.0.0
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
@ -51,6 +51,8 @@ dependencies:
permission_handler: ^10.0.0 permission_handler: ^10.0.0
fluttertoast: ^8.0.9 fluttertoast: ^8.0.9
device_info_plus: ^4.1.2 device_info_plus: ^4.1.2
file_picker: ^5.1.0
animations: ^2.0.4
dev_dependencies: dev_dependencies: