Compare commits

...

17 Commits

Author SHA1 Message Date
f8101a5d9f Updated version 2022-08-28 19:26:13 -04:00
c2a7e4a0d2 Bugfix - update checking on app load was broken 2022-08-28 18:17:03 -04:00
285da7545b Slight offset on ic_notification 2022-08-27 23:56:44 -04:00
a5230acc11 Added store icon 2022-08-27 23:43:54 -04:00
53019818a6 Rearranged some folders, added graphics 2022-08-27 23:01:29 -04:00
1a04d39144 Updated README.md with new App Sources 2022-08-27 22:34:56 -04:00
96c1ed612d Added F-Droid, Mullvad. Bug fixes. 2022-08-27 22:22:59 -04:00
4d75a6a361 Tiny UI tweak 2022-08-27 19:27:16 -04:00
30075add1c Fixed APKPicker radiobutton + preferred apk index saved 2022-08-27 19:17:29 -04:00
52b4e1fb96 bugfix 2022-08-27 18:05:49 -04:00
f9044e20f1 Refactors to source_provider - less redundancy 2022-08-27 18:03:45 -04:00
7e5affe1b8 Added Signal.org, fixed bugs, UX tweaks, readme update 2022-08-27 17:47:08 -04:00
5bdab1b1e4 Remove prev. error notif if any when bg update checking 2022-08-27 16:41:01 -04:00
c14c4d2f14 Back button switches to apps + more haptics 2022-08-27 16:37:27 -04:00
5e785ae1d5 haptic feedback, listed sources 2022-08-27 16:25:45 -04:00
6c076751ab Fixed APK picker + UX tweak 2022-08-27 15:43:29 -04:00
4253203dca Tiny UI/UX tweaks 2022-08-27 04:01:25 -04:00
22 changed files with 450 additions and 179 deletions

View File

@ -1,21 +1,25 @@
# ![](./android/app/src/main/res/drawable/ic_notification.png) Obtainium
# ![Obtainium Icon](./android/app/src/main/res/drawable/ic_notification.png) Obtainium
Get Android App Updates Directly From the Source.
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
Currently supported App sources:
- GitHub
- GitLab
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
Currently supported App sources:
- [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/)
- [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).
## Screenshots
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> |
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
| <img src="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> |
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/graphics/banner.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
assets/graphics/icon.psd Executable file

Binary file not shown.

BIN
assets/graphics/obtainium.psd Executable file

Binary file not shown.

BIN
assets/graphics/store-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 263 KiB

View File

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -11,16 +11,18 @@ import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart';
const String currentReleaseTag =
'v0.1.3-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v0.1.7-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point')
void bgTaskCallback() {
// Background update checking process
Workmanager().executeTask((task, taskName) async {
var appsProvider = AppsProvider(bg: true);
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
var appsProvider = AppsProvider();
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) {
@ -50,7 +52,11 @@ void main() async {
);
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: true,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
],
@ -69,12 +75,7 @@ class MyApp extends StatelessWidget {
AppsProvider appsProvider = context.read<AppsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings().then((value) {
// Delete past downloads and check for updates every time the app is launched
// Only runs once as the settings are only initialized once (so not on every build)
appsProvider.deleteSavedAPKs();
appsProvider.checkUpdates();
});
settingsProvider.initializeSettings();
} else {
// Register the background update task according to the user's setting
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
@ -87,12 +88,14 @@ class MyApp extends StatelessWidget {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request();
appsProvider.saveApp(App(
'imranr98_obtainium_github',
'imranr98_obtainium_${GitHub().host}',
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
currentReleaseTag,
currentReleaseTag, []));
currentReleaseTag,
[],
0));
}
}
@ -123,7 +126,8 @@ class MyApp extends StatelessWidget {
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.light
? lightColorScheme
: darkColorScheme),
: darkColorScheme,
fontFamily: 'Metropolis'),
home: const HomePage());
});
}

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/app.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:url_launcher/url_launcher_string.dart';
class AddAppPage extends StatefulWidget {
const AddAppPage({super.key});
@ -19,16 +21,21 @@ class _AddAppPageState extends State<AddAppPage> {
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
return Center(
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
Container(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextFormField(
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'),
@ -41,28 +48,31 @@ class _AddAppPageState extends State<AddAppPage> {
}
return null;
},
)),
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.mediumImpact();
if (_formKey.currentState!.validate()) {
setState(() {
gettingAppInfo = true;
});
sourceProvider()
sourceProvider
.getApp(urlInputController.value.text)
.then((app) {
var appsProvider = context.read<AppsProvider>();
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
if (appsProvider.apps.containsKey(app.id)) {
throw 'App already added';
}
settingsProvider.getInstallPermission().then((_) {
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider.saveApp(app).then((_) {
urlInputController.clear();
Navigator.push(
@ -86,10 +96,39 @@ class _AddAppPageState extends State<AddAppPage> {
child: const Text('Add'),
),
),
const Spacer(),
if (gettingAppInfo) const LinearProgressIndicator(),
],
),
));
),
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(),
],
)),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart';
@ -45,11 +46,17 @@ class _AppPageState extends State<AppPage> {
appsProvider
.checkAppObjectForUpdate(
app!.app)) &&
app?.downloadProgress == null
!appsProvider.areDownloadsRunning()
? () {
HapticFeedback.heavyImpact();
appsProvider
.downloadAndInstallLatestApp(
[app!.app.id], context);
[app!.app.id],
context).then((res) {
if (res && mounted) {
Navigator.of(context).pop();
}
});
}
: null,
child: Text(app?.app.installedVersion == null
@ -60,6 +67,7 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null
? null
: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
@ -70,6 +78,7 @@ class _AppPageState extends State<AppPage> {
actions: [
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
appsProvider
.removeApp(app!.app.id)
.then((_) {
@ -82,6 +91,7 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove')),
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
child: const Text('Cancel'))

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
@ -21,11 +22,10 @@ class _AppsPageState extends State<AppsPage> {
floatingActionButton: existingUpdateAppIds.isEmpty
? null
: ElevatedButton.icon(
onPressed: appsProvider.apps.values
.where((element) => element.downloadProgress != null)
.isNotEmpty
onPressed: appsProvider.areDownloadsRunning()
? null
: () {
HapticFeedback.heavyImpact();
context
.read<SettingsProvider>()
.getInstallPermission()
@ -45,7 +45,10 @@ class _AppsPageState extends State<AppsPage> {
style: Theme.of(context).textTheme.headline4,
)
: RefreshIndicator(
onRefresh: appsProvider.checkUpdates,
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates();
},
child: ListView(
children: appsProvider.apps.values
.map(
@ -55,7 +58,7 @@ class _AppsPageState extends State<AppsPage> {
e.app.installedVersion ?? 'Not Installed'),
trailing: e.downloadProgress != null
? Text(
'Downloading - ${e.downloadProgress!.toInt()}%')
'Downloading - ${e.downloadProgress?.toInt()}%')
: (e.app.installedVersion != null &&
e.app.installedVersion !=
e.app.latestVersion

View File

@ -1,4 +1,5 @@
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/settings.dart';
@ -20,22 +21,34 @@ class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
return WillPopScope(
child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')),
body: pages.elementAt(selectedIndex),
bottomNavigationBar: NavigationBar(
destinations: const [
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
],
onDestinationSelected: (int index) {
HapticFeedback.lightImpact();
setState(() {
selectedIndex = index;
});
},
selectedIndex: selectedIndex,
),
);
),
onWillPop: () async {
if (selectedIndex != 1) {
setState(() {
selectedIndex = 1;
});
return false;
}
return true;
});
}
}

View File

@ -1,6 +1,7 @@
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';
@ -118,6 +119,7 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: appsProvider.apps.isEmpty
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -128,6 +130,7 @@ class _SettingsPageState extends State<SettingsPage> {
child: const Text('Export Apps')),
ElevatedButton(
onPressed: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
@ -172,6 +175,13 @@ class _SettingsPageState extends State<SettingsPage> {
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
if (formKey.currentState!
.validate()) {
appsProvider
@ -198,11 +208,6 @@ class _SettingsPageState extends State<SettingsPage> {
}
},
child: const Text('Import')),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'))
],
);
});
@ -223,6 +228,7 @@ class _SettingsPageState extends State<SettingsPage> {
}),
),
onPressed: () {
HapticFeedback.lightImpact();
launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication);
},

View File

@ -6,6 +6,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
@ -38,14 +39,26 @@ class AppsProvider with ChangeNotifier {
late Stream<FGBGType> foregroundStream;
late StreamSubscription<FGBGType> foregroundSubscription;
AppsProvider({bool bg = false}) {
AppsProvider(
{bool shouldLoadApps = false,
bool shouldCheckUpdatesAfterLoad = false,
bool shouldDeleteAPKs = false}) {
// Subscribe to changes in the app foreground status
foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundSubscription = foregroundStream.listen((event) async {
isForeground = event == FGBGType.foreground;
if (isForeground) await loadApps();
});
loadApps();
if (shouldDeleteAPKs) {
deleteSavedAPKs();
}
if (shouldLoadApps) {
loadApps().then((_) {
if (shouldCheckUpdatesAfterLoad) {
checkUpdates();
}
});
}
}
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
@ -79,10 +92,14 @@ class AppsProvider with ChangeNotifier {
return ApkFile(appId, downloadFile);
}
bool areDownloadsRunning() => apps.values
.where((element) => element.downloadProgress != null)
.isNotEmpty;
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
// Returns upon successful download, regardless of installation result
Future<void> downloadAndInstallLatestApp(
Future<bool> downloadAndInstallLatestApp(
List<String> appIds, BuildContext context) async {
NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>();
@ -91,37 +108,22 @@ class AppsProvider with ChangeNotifier {
if (apps[id] == null) {
throw 'App not found';
}
String apkUrl = apps[id]!.app.apkUrls.last;
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
if (apps[id]!.app.apkUrls.length > 1) {
await showDialog(
apkUrl = await showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
scrollable: true,
title: const Text('Pick an APK'),
content: Column(children: [
Text(
'${apps[id]!.app.name} has more than one package - pick one.'),
...apps[id]!.app.apkUrls.map((u) => ListTile(
title: Text(Uri.parse(u).pathSegments.last),
leading: Radio<String>(
value: u,
groupValue: apkUrl,
onChanged: (String? val) {
apkUrl = val!;
})))
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Continue'))
],
);
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
});
}
appsToInstall.putIfAbsent(id, () => apkUrl);
if (apkUrl != null) {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) {
apps[id]!.app.preferredApkIndex = urlInd;
await saveApp(apps[id]!.app);
}
appsToInstall.putIfAbsent(id, () => apkUrl!);
}
}
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
@ -131,6 +133,7 @@ class AppsProvider with ChangeNotifier {
await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true);
await FGBGEvents.stream.first == FGBGType.foreground;
await notificationsProvider.cancel(completeInstallationNotification.id);
// We need to wait for the App to come to the foreground to install it
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
// https://github.com/flutter/flutter/issues/13937
@ -144,6 +147,8 @@ class AppsProvider with ChangeNotifier {
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
await saveApp(apps[f.appId]!.app);
}
return downloadedFiles.isNotEmpty;
}
Future<Directory> getAppsDir() async {
@ -209,9 +214,12 @@ class AppsProvider with ChangeNotifier {
Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
App newApp = await sourceProvider().getApp(currentApp.url);
App newApp = await SourceProvider().getApp(currentApp.url);
if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
await saveApp(newApp);
return newApp;
}
@ -281,3 +289,53 @@ class AppsProvider with ChangeNotifier {
super.dispose();
}
}
class APKPicker extends StatefulWidget {
const APKPicker({super.key, required this.app, this.initVal});
final App app;
final String? initVal;
@override
State<APKPicker> createState() => _APKPickerState();
}
class _APKPickerState extends State<APKPicker> {
String? apkUrl;
@override
Widget build(BuildContext context) {
apkUrl ??= widget.initVal;
return AlertDialog(
scrollable: true,
title: const Text('Pick an APK'),
content: Column(children: [
Text('${widget.app.name} has more than one package:'),
const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>(
title: Text(Uri.parse(u).pathSegments.last),
value: u,
groupValue: apkUrl,
onChanged: (String? val) {
setState(() {
apkUrl = val;
});
}))
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.mediumImpact();
Navigator.of(context).pop(apkUrl);
},
child: const Text('Continue'))
],
);
}
}

View File

@ -29,8 +29,9 @@ class App {
String? installedVersion;
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.latestVersion, this.apkUrls, this.preferredApkIndex);
@override
String toString() {
@ -46,7 +47,11 @@ class App {
? null
: json['installedVersion'] as String,
json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls'])));
List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null
? 0
: json['preferredApkIndex'] as int,
);
Map<String, dynamic> toJson() => {
'id': id,
@ -56,6 +61,7 @@ class App {
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex
};
}
@ -77,7 +83,7 @@ List<String> getLinksFromParsedHTML(
.toList();
abstract class AppSource {
late String sourceId;
late String host;
String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl);
@ -85,11 +91,11 @@ abstract class AppSource {
class GitHub implements AppSource {
@override
String sourceId = 'github';
late String host = 'github.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
@ -99,7 +105,6 @@ class GitHub implements AppSource {
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
// The GitHub RSS feed does not contain asset download details, so we use web scraping (avoid API due to rate limits)
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl);
@ -144,11 +149,11 @@ class GitHub implements AppSource {
class GitLab implements AppSource {
@override
String sourceId = 'gitlab';
late String host = 'gitlab.com';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://gitlab.com/[^/]*/[^/]*');
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
@ -158,14 +163,13 @@ class GitLab implements AppSource {
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
// GitLab provides an RSS feed with all the details we need
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);
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = getLinksFromParsedHTML(
entryContent,
RegExp(
@ -176,7 +180,7 @@ class GitLab implements AppSource {
throw 'No APK found';
}
var entryId = entry.querySelector('id')?.innerHtml;
var entryId = entry?.querySelector('id')?.innerHtml;
var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) {
@ -195,16 +199,143 @@ class GitLab implements AppSource {
}
}
class sourceProvider {
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');
}
}
class SourceProvider {
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
// Add more source classes here so they are available via the service
AppSource getSource(String url) {
if (url.toLowerCase().contains('://github.com')) {
return GitHub();
} else if (url.toLowerCase().contains('://gitlab.com')) {
return GitLab();
AppSource? source;
for (var s in sources) {
if (url.toLowerCase().contains('://${s.host}')) {
source = s;
break;
}
}
if (source == null) {
throw 'URL does not match a known source';
}
return source;
}
Future<App> getApp(String url) async {
if (url.toLowerCase().indexOf('http://') != 0 &&
@ -219,12 +350,15 @@ class sourceProvider {
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.sourceId}',
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version,
apk.apkUrls);
apk.apkUrls,
apk.apkUrls.length - 1);
}
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
}

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.3+4 # When changing this, update the tag in main() accordingly
version: 0.1.7+8 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -66,9 +66,9 @@ dev_dependencies:
flutter_icons:
android: true
image_path: "assets/icon.png"
image_path: "assets/graphics/icon.png"
adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/icon.png"
adaptive_icon_foreground: "assets/graphics/icon.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec