Compare commits

...

40 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
5e96b91029 Updated version 2022-09-17 01:43:54 -04:00
5fc79af960 Added App sorting 2022-09-17 01:41:38 -04:00
05f5590e7d Updated modules, removed unneeded imports 2022-09-17 01:10:34 -04:00
50f8caeb47 Added "Already Installed" button 2022-09-17 00:59:15 -04:00
f966a9e626 Finished import/export changes 2022-09-17 00:39:56 -04:00
02a5749ba7 Removed redundant code 2022-09-17 00:09:46 -04:00
4ccf7cbc92 Added GitHub starred import (+ general import/export changes) 2022-09-16 23:52:58 -04:00
ab4efd85ce Added IzzyOnDroid App Source
+ Bugfix for third party APK URL support
+ F-Droid apps have F-Droid as Author now
2022-09-16 20:24:47 -04:00
42bba0f64c Added option to disable background update checking 2022-09-16 19:53:57 -04:00
294327bde4 FIXED GITHUB ISSUE 2022-09-13 21:42:06 -04:00
52b97662c6 Updated plugins, incremented app version, ui tweaks 2022-09-03 17:31:19 -04:00
f63da4b538 Added option to not show App webpage + wording tweak 2022-09-03 17:06:46 -04:00
c30c692d87 Added external APK support (GitLab only for now) 2022-09-03 16:12:25 -04:00
d643d5a474 Fixed invisible nav buttons on pre Android Q 2022-09-03 15:30:00 -04:00
24 changed files with 2244 additions and 716 deletions

View File

@ -10,13 +10,14 @@ Currently supported App sources:
- [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/)
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/)
## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods are either unavailable (e.g. Mullvad), insufficient (e.g. GitHub RSS) or subject to rate limits (e.g. GitHub API).
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
## Screenshots

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

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form.dart';
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key,
required this.title,
required this.items,
required this.defaultValues});
final String title;
final List<List<GeneratedFormItem>> items;
final List<String> defaultValues;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
List<String> values = [];
bool valid = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content: GeneratedForm(
items: widget.items,
onValueChanges: (values, valid) {
setState(() {
this.values = values;
this.valid = valid;
});
},
defaultValues: widget.defaultValues),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: !valid
? null
: () {
if (valid) {
HapticFeedback.selectionClick();
Navigator.of(context).pop(values);
}
},
child: const Text('Continue'))
],
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/pages/home.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
@ -9,9 +10,10 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag =
'v0.1.7-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v0.3.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point')
void bgTaskCallback() {
@ -43,10 +45,12 @@ void bgTaskCallback() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
Workmanager().initialize(
bgTaskCallback,
);
@ -55,7 +59,7 @@ void main() async {
ChangeNotifierProvider(
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: true,
shouldCheckUpdatesAfterLoad: false,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
@ -78,11 +82,15 @@ class MyApp extends StatelessWidget {
settingsProvider.initializeSettings();
} else {
// Register the background update task according to the user's setting
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
if (settingsProvider.updateInterval > 0) {
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval),
initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace);
} else {
Workmanager().cancelByUniqueName('bg-update-check');
}
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
@ -95,7 +103,8 @@ class MyApp extends StatelessWidget {
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/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/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -15,120 +17,187 @@ class AddAppPage extends StatefulWidget {
}
class _AddAppPageState extends State<AddAppPage> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
bool gettingAppInfo = false;
String userInput = "";
AppSource? pickedSource;
List<String> additionalData = [];
bool validAdditionalData = true;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
return Center(
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(),
Padding(
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Add App'),
SliverFillRemaining(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(
hintText: 'https://github.com/Author/Project',
helperText: 'Enter the App source URL'),
controller: urlInputController,
validator: (value) {
if (value == null ||
value.isEmpty ||
Uri.tryParse(value) == null) {
return 'Please enter a supported source URL';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.mediumImpact();
if (_formKey.currentState!.validate()) {
setState(() {
gettingAppInfo = true;
});
sourceProvider
.getApp(urlInputController.value.text)
.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(() {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormItem(
label: "App Source Url",
additionalValidators: [
(value) {
try {
sourceProvider
.getSource(value ?? "")
.standardizeURL(
makeUrlHttps(
value ?? ""));
} catch (e) {
return e is String
? e
: "Error";
}
return null;
}
])
]
],
onValueChanges: (values, valid) {
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;
}
});
});
}
},
child: const Text('Add'),
},
defaultValues: const [])),
const SizedBox(
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'))
],
),
),
],
),
),
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
const Text(
'Supported Sources:',
// style: TextStyle(fontWeight: FontWeight.bold),
// style: Theme.of(context).textTheme.bodySmall,
),
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()
]),
if (gettingAppInfo)
const LinearProgressIndicator()
else
Container(),
],
)),
);
if (pickedSource != null &&
(pickedSource!.additionalDataFormItems.isNotEmpty))
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Divider(
height: 64,
),
Text(
'Additional Options for ${pickedSource?.runtimeType}',
style: TextStyle(
color:
Theme.of(context).colorScheme.primary)),
const SizedBox(
height: 16,
),
GeneratedForm(
items: pickedSource!.additionalDataFormItems,
onValueChanges: (values, valid) {
setState(() {
additionalData = values;
validAdditionalData = valid;
});
},
defaultValues:
pickedSource!.additionalDataDefaults)
],
)
else
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// const SizedBox(
// height: 48,
// ),
const Text(
'Supported Sources:',
),
const SizedBox(
height: 8,
),
...sourceProvider
.getSourceHosts()
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://$e',
mode:
LaunchMode.externalApplication);
},
child: Text(
e,
style: const TextStyle(
decoration:
TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
])),
])),
)
]));
}
}

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.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/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart';
@ -17,18 +21,68 @@ class _AppPageState extends State<AppPage> {
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.app.id);
}
return Scaffold(
appBar: AppBar(
title: Text('${app?.app.author}/${app?.app.name}'),
),
body: WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
),
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
backgroundColor: Theme.of(context).colorScheme.surface,
body: settingsProvider.showAppWebpage
? WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
app?.app.name ?? 'App',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayLarge,
),
Text(
'By ${app?.app.author ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(
height: 32,
),
GestureDetector(
onTap: () {
if (app?.app.url != null) {
launchUrlString(app?.app.url ?? '',
mode: LaunchMode.externalApplication);
}
},
child: Text(
app?.app.url ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic,
fontSize: 12),
)),
const SizedBox(
height: 32,
),
Text(
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom),
@ -40,6 +94,107 @@ class _AppPageState extends State<AppPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
updatedApp.latestVersion;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Installed'))
],
);
});
},
tooltip: 'Mark as Installed',
icon: const Icon(Icons.done))
else
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('App Not Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
null;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Not Installed'))
],
);
});
},
tooltip: 'Mark as Not Installed',
icon: const Icon(Icons.no_cell_outlined)),
if (source != null &&
source.additionalDataFormItems.isNotEmpty)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source.additionalDataFormItems,
defaultValues:
source.additionalDataDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
sourceProvider
.getApp(source, changedApp.url,
changedApp.additionalData)
.then((finalChangedApp) {
appsProvider.saveApp(finalChangedApp);
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
}
});
},
icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0),
Expanded(
child: ElevatedButton(
onPressed: (app?.app.installedVersion == null ||
@ -67,7 +222,6 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null
? null
: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
@ -78,7 +232,8 @@ class _AppPageState extends State<AppPage> {
actions: [
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
HapticFeedback
.selectionClick();
appsProvider
.removeApp(app!.app.id)
.then((_) {
@ -91,7 +246,6 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove')),
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
child: const Text('Cancel'))
@ -100,8 +254,10 @@ class _AppPageState extends State<AppPage> {
});
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).errorColor,
surfaceTintColor: Theme.of(context).errorColor),
foregroundColor:
Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: const Text('Remove'),
),
])),

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -13,70 +16,194 @@ class AppsPage extends StatefulWidget {
}
class _AppsPageState extends State<AppsPage> {
AppsFilter? filter;
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates();
var sortedApps = appsProvider.apps.values.toList();
if (filter != null) {
sortedApps = sortedApps.where((app) {
if (app.app.installedVersion == app.app.latestVersion &&
filter!.onlyNonLatest) {
return false;
}
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
return true;
}
List<String> nameTokens = filter!.nameFilter
.split(' ')
.where((element) => element.trim().isNotEmpty)
.toList();
List<String> authorTokens = filter!.authorFilter
.split(' ')
.where((element) => element.trim().isNotEmpty)
.toList();
for (var t in nameTokens) {
if (!app.app.name.toLowerCase().contains(t.toLowerCase())) {
return false;
}
}
for (var t in authorTokens) {
if (!app.app.author.toLowerCase().contains(t.toLowerCase())) {
return false;
}
}
return true;
}).toList();
}
sortedApps.sort((a, b) {
int result = 0;
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
result =
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
result =
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
}
return result;
});
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
sortedApps = sortedApps.reversed.toList();
}
return Scaffold(
floatingActionButton: existingUpdateAppIds.isEmpty
? null
: ElevatedButton.icon(
onPressed: appsProvider.areDownloadsRunning()
? null
: () {
HapticFeedback.heavyImpact();
context
.read<SettingsProvider>()
.getInstallPermission()
.then((_) {
appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context);
backgroundColor: Theme.of(context).colorScheme.surface,
floatingActionButton:
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
existingUpdateAppIds.isEmpty || filter != null
? const SizedBox()
: ElevatedButton.icon(
onPressed: appsProvider.areDownloadsRunning()
? null
: () {
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");
});
},
icon: const Icon(Icons.update),
label: const Text('Update All')),
body: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: appsProvider.apps.isEmpty
? Text(
'No Apps',
style: Theme.of(context).textTheme.headline4,
)
: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates();
},
child: ListView(
children: appsProvider.apps.values
.map(
(e) => ListTile(
title: Text('${e.app.author}/${e.app.name}'),
subtitle: Text(
e.app.installedVersion ?? 'Not Installed'),
trailing: e.downloadProgress != null
? Text(
'Downloading - ${e.downloadProgress?.toInt()}%')
: (e.app.installedVersion != null &&
e.app.installedVersion !=
e.app.latestVersion
? const Text('Update Available')
: null),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: e.app.id)),
);
},
),
)
.toList(),
),
),
));
} else {
setState(() {
filter = null;
});
}
});
},
label: Text(filter == null ? 'Search' : 'Modify Search'),
icon: Icon(
filter == null ? Icons.search : Icons.manage_search)),
]),
body: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
},
child: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Apps'),
if (appsProvider.loadingApps || sortedApps.isEmpty)
SliverFillRemaining(
child: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: Text(
appsProvider.apps.isEmpty
? 'No Apps'
: 'No Search Results',
style:
Theme.of(context).textTheme.headlineMedium,
))),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
title: Text(sortedApps[index].app.name),
subtitle: Text('By ${sortedApps[index].app.author}'),
trailing: sortedApps[index].downloadProgress != null
? 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,7 +1,9 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart';
class HomePage extends StatefulWidget {
@ -11,40 +13,78 @@ class HomePage extends StatefulWidget {
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> {
int selectedIndex = 1;
List<Widget> pages = [
const SettingsPage(),
const AppsPage(),
const AddAppPage()
List<int> selectedIndexHistory = [];
List<NavigationPageItem> pages = [
NavigationPageItem('Apps', Icons.apps, const AppsPage()),
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
];
@override
Widget build(BuildContext context) {
return WillPopScope(
child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')),
body: pages.elementAt(selectedIndex),
backgroundColor: Theme.of(context).colorScheme.surface,
body: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: pages
.elementAt(selectedIndexHistory.isEmpty
? 0
: selectedIndexHistory.last)
.widget,
),
bottomNavigationBar: NavigationBar(
destinations: const [
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
],
destinations: pages
.map((e) =>
NavigationDestination(icon: Icon(e.icon), label: e.title))
.toList(),
onDestinationSelected: (int index) {
HapticFeedback.lightImpact();
HapticFeedback.selectionClick();
setState(() {
selectedIndex = index;
if (index == 0) {
selectedIndexHistory.clear();
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
}
});
},
selectedIndex: selectedIndex,
selectedIndex:
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
),
),
onWillPop: () async {
if (selectedIndex != 1) {
if (selectedIndexHistory.isNotEmpty) {
setState(() {
selectedIndex = 1;
selectedIndexHistory.removeLast();
});
return false;
}

View File

@ -0,0 +1,377 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@override
State<ImportExportPage> createState() => _ImportExportPageState();
}
class _ImportExportPageState extends State<ImportExportPage> {
bool importInProgress = false;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>();
var outlineButtonStyle = ButtonStyle(
shape: MaterialStateProperty.all(
StadiumBorder(
side: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls);
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApp(app);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Import/Export'),
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: appsProvider.apps.isEmpty ||
importInProgress
? null
: () {
HapticFeedback.selectionClick();
appsProvider
.exportApps()
.then((String path) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Exported to $path')),
);
});
},
child: const Text('Obtainium Export'))),
const SizedBox(
width: 16,
),
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed: importInProgress
? null
: () {
HapticFeedback.selectionClick();
FilePicker.platform
.pickFiles()
.then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw 'Invalid input';
}
appsProvider
.importApps(data)
.then((value) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
});
} else {
// User canceled the picker
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: const Text('Obtainium Import')))
],
),
if (importInProgress)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import from URL List',
items: [
[
GeneratedFormItem(
label: 'App URL List',
max: 7,
additionalValidators: [
(String? value) {
if (value != null &&
value.isNotEmpty) {
var lines = value
.trim()
.split('\n');
for (int i = 0;
i < lines.length;
i++) {
try {
sourceProvider
.getSource(
lines[i]);
} catch (e) {
return 'Line ${i + 1}: $e';
}
}
}
return null;
}
])
]
],
defaultValues: const [],
);
}).then((values) {
if (values != null) {
var urls =
(values[0] as String).split('\n');
setState(() {
importInProgress = true;
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
});
},
child: const Text(
'Import from URL List',
)),
...sourceProvider.massSources
.map((source) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return GeneratedFormModal(
title:
'Import ${source.name}',
items: source
.requiredArgs
.map((e) => [
GeneratedFormItem(
label: e)
])
.toList(),
defaultValues: const [],
);
}).then((values) {
if (values != null) {
source
.getUrls(values)
.then((urls) {
setState(() {
importInProgress = true;
});
addApps(urls)
.then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder:
(BuildContext
ctx) {
return ImportErrorDialog(
urlsLength: urls
.length,
errors:
errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress =
false;
});
});
}).catchError((e) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
e.toString())),
);
});
}
});
},
child: Text('Import ${source.name}'))
]))
.toList()
],
)))
]));
}
}
class ImportErrorDialog extends StatefulWidget {
const ImportErrorDialog(
{super.key, required this.urlsLength, required this.errors});
final int urlsLength;
final List<List<String>> errors;
@override
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
}
class _ImportErrorDialogState extends State<ImportErrorDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Import Errors'),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'The following URLs had errors:',
style: Theme.of(context).textTheme.bodyLarge,
),
...widget.errors.map((e) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(
height: 16,
),
Text(e[0]),
Text(
e[1],
style: const TextStyle(fontStyle: FontStyle.italic),
)
]);
}).toList()
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Okay'))
],
);
}
}

View File

@ -1,8 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -17,230 +14,223 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) {
AppsProvider appsProvider = context.read<AppsProvider>();
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings();
}
return Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? Container()
: Column(
children: [
DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration: const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: const [
DropdownMenuItem(
value: 15,
child: Text('15 Minutes'),
),
DropdownMenuItem(
value: 30,
child: Text('30 Minutes'),
),
DropdownMenuItem(
value: 60,
child: Text('1 Hour'),
),
DropdownMenuItem(
value: 360,
child: Text('6 Hours'),
),
DropdownMenuItem(
value: 720,
child: Text('12 Hours'),
),
DropdownMenuItem(
value: 1440,
child: Text('1 Day'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.updateInterval = value;
}
}),
const SizedBox(
height: 32,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
ElevatedButton(
onPressed: appsProvider.apps.isEmpty
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Exported to $path')),
);
});
},
child: const Text('Export Apps')),
ElevatedButton(
onPressed: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
final formKey = GlobalKey<FormState>();
final jsonInputController =
TextEditingController();
return AlertDialog(
scrollable: true,
title: const Text('Import Apps'),
content: Column(children: [
const Text(
'Copy the contents of the Obtainium export file and paste them into the field below:'),
Form(
key: formKey,
child: TextFormField(
minLines: 7,
maxLines: 7,
decoration: const InputDecoration(
helperText:
'Obtainium export data'),
controller: jsonInputController,
validator: (value) {
if (value == null ||
value.isEmpty) {
return 'Please enter your Obtainium export data';
}
bool isJSON = true;
try {
jsonDecode(value);
} catch (e) {
isJSON = false;
}
if (!isJSON) {
return 'Invalid input';
}
return null;
},
),
)
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
if (formKey.currentState!
.validate()) {
appsProvider
.importApps(
jsonInputController
.value.text)
.then((value) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'$value Apps Imported')),
);
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content:
Text(e.toString())),
);
}).whenComplete(() {
Navigator.of(context).pop();
});
}
},
child: const Text('Import')),
],
);
});
},
child: const Text('Import Apps'))
],
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Colors.grey;
}),
),
onPressed: () {
HapticFeedback.lightImpact();
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},
icon: const Icon(Icons.code),
label: Text(
'Source',
style: Theme.of(context).textTheme.caption,
),
)
],
),
],
));
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Settings'),
SliverFillRemaining(
hasScrollBody: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: settingsProvider.prefs == null
? const SizedBox()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Theme'),
value: settingsProvider.theme,
items: const [
DropdownMenuItem(
value: ThemeSettings.dark,
child: Text('Dark'),
),
DropdownMenuItem(
value: ThemeSettings.light,
child: Text('Light'),
),
DropdownMenuItem(
value: ThemeSettings.system,
child: Text('Follow System'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.theme = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'Colour'),
value: settingsProvider.colour,
items: const [
DropdownMenuItem(
value: ColourSettings.basic,
child: Text('Obtainium'),
),
DropdownMenuItem(
value: ColourSettings.materialYou,
child: Text('Material You'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.colour = value;
}
}),
const SizedBox(
height: 16,
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value:
SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value:
SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
})),
const SizedBox(
width: 16,
),
Expanded(
child: DropdownButtonFormField(
decoration: const InputDecoration(
labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
})),
],
),
const SizedBox(
height: 16,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Show Source Webpage in App View'),
Switch(
value: settingsProvider.showAppWebpage,
onChanged: (value) {
settingsProvider.showAppWebpage = value;
})
],
),
const Divider(
height: 16,
),
const SizedBox(
height: 16,
),
Text(
'More',
style: TextStyle(
color: Theme.of(context).colorScheme.primary),
),
DropdownButtonFormField(
decoration: const InputDecoration(
labelText:
'Background Update Checking Interval'),
value: settingsProvider.updateInterval,
items: const [
DropdownMenuItem(
value: 15,
child: Text('15 Minutes'),
),
DropdownMenuItem(
value: 30,
child: Text('30 Minutes'),
),
DropdownMenuItem(
value: 60,
child: Text('1 Hour'),
),
DropdownMenuItem(
value: 360,
child: Text('6 Hours'),
),
DropdownMenuItem(
value: 720,
child: Text('12 Hours'),
),
DropdownMenuItem(
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

@ -108,6 +108,7 @@ class AppsProvider with ChangeNotifier {
if (apps[id] == null) {
throw 'App not found';
}
// If the App has more than one APK, the user should pick one
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
if (apps[id]!.app.apkUrls.length > 1) {
apkUrl = await showDialog(
@ -116,6 +117,19 @@ class AppsProvider with ChangeNotifier {
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
});
}
// If the picked APK comes from an origin different from the source, get user confirmation
if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
return APKOriginWarningDialog(
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
}) !=
true) {
apkUrl = null;
}
}
if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) {
@ -214,7 +228,11 @@ class AppsProvider with ChangeNotifier {
Future<App?> getUpdate(String appId) async {
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) {
newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
@ -325,13 +343,12 @@ class _APKPickerState extends State<APKPicker> {
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.mediumImpact();
HapticFeedback.selectionClick();
Navigator.of(context).pop(apkUrl);
},
child: const Text('Continue'))
@ -339,3 +356,39 @@ class _APKPickerState extends State<APKPicker> {
);
}
}
class APKOriginWarningDialog extends StatefulWidget {
const APKOriginWarningDialog(
{super.key, required this.sourceUrl, required this.apkUrl});
final String sourceUrl;
final String apkUrl;
@override
State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
}
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Warning'),
content: Text(
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.of(context).pop(true);
},
child: const Text('Continue'))
],
);
}
}

View File

@ -9,6 +9,10 @@ enum ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou }
enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending }
class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs;
@ -45,7 +49,27 @@ class SettingsProvider with ChangeNotifier {
}
set updateInterval(int min) {
prefs?.setInt('updateInterval', min < 15 ? 15 : min);
prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min);
notifyListeners();
}
SortColumnSettings get sortColumn {
return SortColumnSettings
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index];
}
set sortColumn(SortColumnSettings s) {
prefs?.setInt('sortColumn', s.index);
notifyListeners();
}
SortOrderSettings get sortOrder {
return SortOrderSettings.values[
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
}
set sortOrder(SortOrderSettings s) {
prefs?.setInt('sortOrder', s.index);
notifyListeners();
}
@ -69,4 +93,13 @@ class SettingsProvider with ChangeNotifier {
}
}
}
bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? true;
}
set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show);
notifyListeners();
}
}

View File

@ -4,8 +4,14 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:html/parser.dart';
import 'package:obtainium/app_sources/fdroid.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 {
late String author;
@ -30,8 +36,17 @@ class App {
late String latestVersion;
List<String> apkUrls = [];
late int preferredApkIndex;
App(this.id, this.url, this.author, this.name, this.installedVersion,
this.latestVersion, this.apkUrls, this.preferredApkIndex);
late List<String> additionalData;
App(
this.id,
this.url,
this.author,
this.name,
this.installedVersion,
this.latestVersion,
this.apkUrls,
this.preferredApkIndex,
this.additionalData);
@override
String toString() {
@ -39,19 +54,21 @@ class App {
}
factory App.fromJson(Map<String, dynamic> json) => App(
json['id'] as String,
json['url'] as String,
json['author'] as String,
json['name'] as String,
json['installedVersion'] == null
? null
: json['installedVersion'] as String,
json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null
? 0
: json['preferredApkIndex'] as int,
);
json['id'] as String,
json['url'] as String,
json['author'] as String,
json['name'] as String,
json['installedVersion'] == null
? null
: json['installedVersion'] as String,
json['latestVersion'] as String,
json['apkUrls'] == null
? []
: List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int,
json['additionalData'] == null
? SourceProvider().getSource(json['url']).additionalDataDefaults
: List<String>.from(jsonDecode(json['additionalData'])));
Map<String, dynamic> toJson() => {
'id': id,
@ -61,7 +78,8 @@ class App {
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex
'preferredApkIndex': preferredApkIndex,
'additionalData': jsonEncode(additionalData)
};
}
@ -71,6 +89,26 @@ escapeRegEx(String s) {
});
}
makeUrlHttps(String url) {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
return url;
}
const String couldNotFindReleases = 'Could not find a suitable release';
const String couldNotFindLatestVersion =
'Could not determine latest release version';
String notValidURL(String sourceName) {
return 'Not a valid $sourceName App URL';
}
const String noAPKFound = 'No APK found';
List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) =>
dom
@ -85,245 +123,35 @@ List<String> getLinksFromParsedHTML(
abstract class AppSource {
late String host;
String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl);
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData);
AppNames getAppNames(String standardUrl);
late List<List<GeneratedFormItem>> additionalDataFormItems;
late List<String> additionalDataDefaults;
}
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 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var apkUrlList = getLinksFromParsedHTML(
parsedHtml,
RegExp(
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin);
if (apkUrlList.isEmpty) {
throw 'No APK found';
}
String getTag(String url) {
List<String> parts = url.split('/');
return parts[parts.length - 2];
}
String latestTag = getTag(apkUrlList[0]);
String? version = parsedHtml
.querySelector('.octicon-tag')
?.nextElementSibling
?.innerHtml
.trim();
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version,
apkUrlList.where((element) => getTag(element) == latestTag).toList());
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
return AppNames(names[0], names[1]);
}
}
class GitLab implements AppSource {
@override
late String host = 'gitlab.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry');
var entryContent =
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = getLinksFromParsedHTML(
entryContent,
RegExp(
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
caseSensitive: false),
standardUri.origin);
if (apkUrlList.isEmpty) {
throw 'No APK found';
}
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, apkUrlList);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
// Same as GitHub
return GitHub().getAppNames(standardUrl);
}
}
class Signal implements AppSource {
@override
late String host = 'signal.org';
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw 'No APK found';
}
String? version = json['versionName'];
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
}
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var latestReleaseDiv =
parse(res.body).querySelector('#latest.package-version');
var apkUrl = latestReleaseDiv
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw 'No APK found';
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
var name = Uri.parse(standardUrl).pathSegments.last;
return AppNames(name, name);
}
}
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw 'Could not determine the latest release version';
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw 'Unable to fetch release info';
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
abstract class MassAppSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
}
class SourceProvider {
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
// Add more source classes here so they are available via the service
List<AppSource> sources = [
GitHub(),
GitLab(),
FDroid(),
IzzyOnDroid(),
Mullvad(),
Signal()
];
// Add more mass source classes here so they are available via the service
List<MassAppSource> massSources = [GitHubStars()];
AppSource getSource(String url) {
url = makeUrlHttps(url);
AppSource? source;
for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) {
@ -337,18 +165,23 @@ class SourceProvider {
return source;
}
Future<App> getApp(String url) async {
if (url.toLowerCase().indexOf('http://') != 0 &&
url.toLowerCase().indexOf('https://') != 0) {
url = 'https://$url';
bool doesSourceHaveRequiredAdditionalData(AppSource source) {
for (var row in source.additionalDataFormItems) {
for (var element in row) {
if (element.required) {
return true;
}
}
}
if (url.toLowerCase().indexOf('https://www.') == 0) {
url = 'https://${url.substring(12)}';
}
AppSource source = getSource(url);
String standardUrl = source.standardizeURL(url);
return false;
}
Future<App> getApp(
AppSource source, String url, List<String> additionalData) async {
String standardUrl = source.standardizeURL(makeUrlHttps(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
APKDetails apk =
await source.getLatestAPKDetails(standardUrl, additionalData);
return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
standardUrl,
@ -357,7 +190,24 @@ class SourceProvider {
null,
apk.version,
apk.apkUrls,
apk.apkUrls.length - 1);
apk.apkUrls.length - 1,
additionalData);
}
/// Returns a length 2 list, where the first element is a list of Apps and
/// the second is a Map<String, dynamic> of URLs and errors
Future<List<dynamic>> getApps(List<String> urls) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls) {
try {
var source = getSource(url);
apps.add(await getApp(source, url, source.additionalDataDefaults));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
List<String> getSourceHosts() => sources.map((e) => e.host).toList();

View File

@ -1,6 +1,13 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
animations:
dependency: "direct main"
description:
name: animations
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
archive:
dependency: transitive
description:
@ -92,13 +99,55 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.8"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.2"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
url: "https://pub.dartlang.org"
source: hosted
version: "1.5.3"
version: "1.5.4"
fake_async:
dependency: transitive
description:
@ -120,6 +169,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description: flutter
@ -152,21 +208,28 @@ packages:
name: flutter_local_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "9.8.0+1"
version: "10.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
version: "1.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
url: "https://pub.dartlang.org"
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:
dependency: "direct dev"
description: flutter
@ -323,7 +386,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
permission_handler:
dependency: "direct main"
description:
@ -379,7 +442,7 @@ packages:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
process:
dependency: transitive
description:
@ -407,7 +470,7 @@ packages:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.12"
version: "2.0.13"
shared_preferences_ios:
dependency: transitive
description:
@ -435,7 +498,7 @@ packages:
name: shared_preferences_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
version: "2.1.0"
shared_preferences_web:
dependency: transitive
description:
@ -496,7 +559,7 @@ packages:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.12"
version: "0.4.14"
timezone:
dependency: transitive
description:
@ -524,7 +587,7 @@ packages:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
version: "6.0.19"
url_launcher_ios:
dependency: transitive
description:
@ -573,7 +636,7 @@ packages:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
webview_flutter:
dependency: "direct main"
description:
@ -587,28 +650,28 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.5"
version: "2.10.1"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.2"
version: "1.9.3"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
url: "https://pub.dartlang.org"
source: hosted
version: "2.9.3"
version: "2.9.4"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.7.0"
version: "3.0.0"
workmanager:
dependency: "direct main"
description:
@ -639,4 +702,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0-79.0.dev <3.0.0"
flutter: ">=3.1.0-0.0.pre.1036"
flutter: ">=3.3.0"

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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.1.7+8 # When changing this, update the tag in main() accordingly
version: 0.3.1+17 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -35,21 +35,24 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
cupertino_icons: ^1.0.5
path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^9.8.0+1
flutter_local_notifications: ^10.0.0
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
workmanager: ^0.5.0
dynamic_color: ^1.5.3
dynamic_color: ^1.5.4
install_plugin_v2: ^1.0.0 # Try replacing this
html: ^0.15.0
shared_preferences: ^2.0.15
url_launcher: ^6.1.5
permission_handler: ^10.0.0
fluttertoast: ^8.0.9
device_info_plus: ^4.1.2
file_picker: ^5.1.0
animations: ^2.0.4
dev_dependencies:
@ -62,7 +65,7 @@ dev_dependencies:
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^2.0.0
flutter_lints: ^2.0.1
flutter_icons:
android: true