Compare commits

...

10 Commits

Author SHA1 Message Date
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
13 changed files with 715 additions and 189 deletions

View File

@ -16,7 +16,7 @@ Currently supported App sources:
## 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,81 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class GeneratedFormItem {
late String message;
late bool required;
late int lines;
GeneratedFormItem(this.message, this.required, this.lines);
}
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key, required this.title, required this.items});
final String title;
final List<GeneratedFormItem> items;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
@override
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(
scrollable: true,
title: Text(widget.title),
content: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [...formInputs.map((e) => e[1] as Widget)],
)),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
if (_formKey.currentState?.validate() == true) {
HapticFeedback.heavyImpact();
Navigator.of(context).pop(formInputs
.map((e) => (e[0] as TextEditingController).value.text)
.toList());
}
},
child: const Text('Continue'))
],
);
}
}
// TODO: Add support for larger textarea so this can be used for text/json imports

View File

@ -12,7 +12,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag =
'v0.1.8-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v0.2.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point')
void bgTaskCallback() {
@ -81,11 +81,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

View File

@ -91,6 +91,42 @@ class _AppPageState extends State<AppPage> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (app?.app.installedVersion == null)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text(
'App Already Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
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)),
if (app?.app.installedVersion == null)
const SizedBox(width: 16.0),
Expanded(
child: ElevatedButton(
onPressed: (app?.app.installedVersion == null ||

View File

@ -16,7 +16,23 @@ class _AppsPageState extends State<AppsPage> {
@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();
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
@ -26,10 +42,7 @@ class _AppsPageState extends State<AppsPage> {
? null
: () {
HapticFeedback.heavyImpact();
context
.read<SettingsProvider>()
.getInstallPermission()
.then((_) {
settingsProvider.getInstallPermission().then((_) {
appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context);
});
@ -50,7 +63,7 @@ class _AppsPageState extends State<AppsPage> {
return appsProvider.checkUpdates();
},
child: ListView(
children: appsProvider.apps.values
children: sortedApps
.map(
(e) => ListTile(
title: Text('${e.app.author}/${e.app.name}'),

View File

@ -2,6 +2,7 @@ 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 {
@ -12,11 +13,12 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
int selectedIndex = 1;
List<int> selectedIndexHistory = [];
List<Widget> pages = [
const SettingsPage(),
const AppsPage(),
const AddAppPage()
const AddAppPage(),
const ImportExportPage(),
const SettingsPage()
];
@override
@ -24,27 +26,41 @@ class _HomePageState extends State<HomePage> {
return WillPopScope(
child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')),
body: pages.elementAt(selectedIndex),
body: pages.elementAt(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last),
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'),
NavigationDestination(
icon: Icon(Icons.import_export), label: 'Import/Export'),
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
],
onDestinationSelected: (int index) {
HapticFeedback.lightImpact();
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,284 @@
import 'dart:convert';
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:provider/provider.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@override
State<ImportExportPage> createState() => _ImportExportPageState();
}
class _ImportExportPageState extends State<ImportExportPage> {
bool gettingAppInfo = false;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>();
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 Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
onPressed: appsProvider.apps.isEmpty || gettingAppInfo
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Exported to $path')),
);
});
},
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: [
const SizedBox(height: 8),
TextButton(
onPressed: gettingAppInfo
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import ${source.name}',
items: source.requiredArgs
.map((e) =>
GeneratedFormItem(
e, true, 1))
.toList());
}).then((values) {
if (values != null) {
source.getUrls(values).then((urls) {
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);
});
}
}).whenComplete(() {
setState(() {
gettingAppInfo = 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: () {
HapticFeedback.lightImpact();
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/providers/settings_provider.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -17,7 +14,6 @@ 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();
@ -103,6 +99,10 @@ class _SettingsPageState extends State<SettingsPage> {
value: 1440,
child: Text('1 Day'),
),
DropdownMenuItem(
value: 0,
child: Text('Never - Manual Only'),
),
],
onChanged: (value) {
if (value != null) {
@ -112,6 +112,54 @@ class _SettingsPageState extends State<SettingsPage> {
const SizedBox(
height: 16,
),
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(
height: 16,
),
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: [
@ -123,112 +171,6 @@ class _SettingsPageState extends State<SettingsPage> {
})
],
),
const SizedBox(
height: 16,
),
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 App List')),
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 App List'),
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 App${value == 1 ? '' : 's'} 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 App List'))
],
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.center,

View File

@ -119,7 +119,7 @@ class AppsProvider with ChangeNotifier {
}
// If the picked APK comes from an origin different from the source, get user confirmation
if (apkUrl != null &&
!apkUrl.toLowerCase().startsWith(apps[id]!.app.url.toLowerCase())) {
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {

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();
}

View File

@ -71,6 +71,12 @@ escapeRegEx(String s) {
});
}
const String couldNotFindReleases = 'Unable to fetch release info';
const String couldNotFindLatestVersion =
'Could not determine latest release version';
const String notValidURL = 'Not a valid URL';
const String noAPKFound = 'No APK found';
List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) =>
dom
@ -98,44 +104,53 @@ class GitHub implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
Response res = await get(Uri.parse(
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
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';
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;
}
String getTag(String url) {
List<String> parts = url.split('/');
return parts[parts.length - 2];
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';
}
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';
throw couldNotFindReleases;
}
}
@ -156,7 +171,7 @@ class GitLab implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
throw notValidURL;
}
return url.substring(0, match.end);
}
@ -184,18 +199,18 @@ class GitLab implements AppSource {
.toList()
];
if (apkUrlList.isEmpty) {
throw 'No APK found';
throw noAPKFound;
}
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';
throw couldNotFindLatestVersion;
}
return APKDetails(version, apkUrlList);
} else {
throw 'Unable to fetch release info';
throw couldNotFindReleases;
}
}
@ -223,15 +238,15 @@ class Signal implements AppSource {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw 'No APK found';
throw noAPKFound;
}
String? version = json['versionName'];
if (version == null) {
throw 'Could not determine latest release version';
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
throw couldNotFindReleases;
}
}
@ -248,7 +263,7 @@ class FDroid implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
throw notValidURL;
}
return url.substring(0, match.end);
}
@ -263,7 +278,7 @@ class FDroid implements AppSource {
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw 'No APK found';
throw noAPKFound;
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
@ -271,18 +286,17 @@ class FDroid implements AppSource {
.split(' ')
.last;
if (version == null) {
throw 'Could not determine latest release version';
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw 'Unable to fetch release info';
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
var name = Uri.parse(standardUrl).pathSegments.last;
return AppNames(name, name);
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
}
@ -295,7 +309,7 @@ class Mullvad implements AppSource {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
throw notValidURL;
}
return url.substring(0, match.end);
}
@ -311,12 +325,12 @@ class Mullvad implements AppSource {
?.split('/')
.last;
if (version == null) {
throw 'Could not determine the latest release version';
throw couldNotFindLatestVersion;
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw 'Unable to fetch release info';
throw couldNotFindReleases;
}
}
@ -326,8 +340,71 @@ class Mullvad implements AppSource {
}
}
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 {
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
List<AppSource> sources = [
GitHub(),
GitLab(),
FDroid(),
Mullvad(),
Signal(),
IzzyOnDroid()
];
List<MassAppSource> massSources = [GitHubStars()];
// Add more source classes here so they are available via the service
AppSource getSource(String url) {
@ -367,5 +444,54 @@ class SourceProvider {
apk.apkUrls.length - 1);
}
/// 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 {
apps.add(await getApp(url));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
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

@ -133,7 +133,7 @@ packages:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
version: "4.1.0"
dynamic_color:
dependency: "direct main"
description:
@ -365,7 +365,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:
@ -421,7 +421,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:
@ -449,7 +449,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:
@ -538,7 +538,7 @@ packages:
name: test_api
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.13"
version: "0.4.14"
timezone:
dependency: transitive
description:
@ -566,7 +566,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:
@ -629,7 +629,7 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.10.0"
version: "2.10.1"
webview_flutter_platform_interface:
dependency: transitive
description:
@ -643,14 +643,14 @@ packages:
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:

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.8+9 # When changing this, update the tag in main() accordingly
version: 0.2.0+11 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'