Compare commits

...

13 Commits

19 changed files with 790 additions and 322 deletions

View File

@ -1,4 +1,4 @@
# Obtainium
# ![](./android/app/src/main/res/drawable/ic_notification.png) Obtainium
Get Android App Updates Directly From the Source.
@ -6,12 +6,16 @@ Obtainium allows you to install and update Open-Source Apps directly from their
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)
***Work In Progress - Far from ready.***
## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
- Already installed apps are not detected, for the above reason along with the fact that App sources do not provide App IDs (like `org.example.app`) to allow for comparisons.
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
## 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="./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" /> |

View File

@ -1,56 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/pages/home.dart';
import 'package:obtainium/services/apps_provider.dart';
import 'package:obtainium/services/settings_provider.dart';
import 'package:obtainium/services/source_service.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'package:dynamic_color/dynamic_color.dart';
const String currentReleaseTag =
'v0.1.4-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point')
void backgroundUpdateCheck() {
Workmanager().executeTask((task, inputData) async {
void bgTaskCallback() {
// Background update checking process
Workmanager().executeTask((task, taskName) async {
var appsProvider = AppsProvider(bg: true);
await appsProvider.notify(
4,
'Checking for Updates',
'',
'BG_UPDATE_CHECK',
'Checking for Updates',
'Transient notification that appears when checking for updates',
important: false);
var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification);
try {
await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) {
String message = updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
await appsProvider.downloaderNotifications.cancel(2);
await appsProvider.notify(
2,
'Updates Available',
message,
'UPDATES_AVAILABLE',
'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium');
notificationsProvider.notify(UpdateNotification(updates),
cancelExisting: true);
}
return Future.value(true);
} catch (e) {
await appsProvider.downloaderNotifications.cancel(5);
await appsProvider.notify(
5,
'Error Checking for Updates',
e.toString(),
'BG_UPDATE_CHECK_ERROR',
'Error Checking for Updates',
'A notification that shows when background update checking fails',
important: false);
notificationsProvider.notify(
ErrorCheckingUpdatesNotification(e.toString()),
cancelExisting: true);
return Future.value(false);
} finally {
await appsProvider.downloaderNotifications.cancel(4);
await notificationsProvider.cancel(checkingUpdatesNotification.id);
}
});
}
@ -62,18 +46,13 @@ void main() async {
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
Workmanager().initialize(
backgroundUpdateCheck,
bgTaskCallback,
);
await Workmanager().cancelByUniqueName('update-apps-task');
await Workmanager().registerPeriodicTask(
'update-apps-task', 'backgroundUpdateCheck',
frequency: const Duration(minutes: 15),
initialDelay: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected));
runApp(MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()),
ChangeNotifierProvider(create: (context) => SettingsProvider())
ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider())
],
child: const MyApp(),
));
@ -86,32 +65,40 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
AppsProvider appsProvider = context.read<AppsProvider>();
appsProvider.deleteSavedAPKs();
// Initialize the settings provider (if needed) and perform first-run actions if needed
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
AppsProvider appsProvider = context.read<AppsProvider>();
if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings().then((_) {
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();
});
} 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);
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
if (isFirstRun) {
appsProvider.downloaderNotifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()!
.requestPermission();
// 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',
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
'v0.1.2-beta', // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v0.1.2-beta',
''));
currentReleaseTag,
currentReleaseTag, []));
}
});
}
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
// Decide on a colour/brightness scheme based on OS and user settings
ColorScheme lightColorScheme;
ColorScheme darkColorScheme;
if (lightDynamic != null &&
@ -124,7 +111,6 @@ class MyApp extends StatelessWidget {
darkColorScheme = ColorScheme.fromSeed(
seedColor: defaultThemeColour, brightness: Brightness.dark);
}
return MaterialApp(
title: 'Obtainium',
theme: ThemeData(
@ -137,7 +123,8 @@ class MyApp extends StatelessWidget {
useMaterial3: true,
colorScheme: settingsProvider.theme == ThemeSettings.light
? lightColorScheme
: darkColorScheme),
: darkColorScheme,
fontFamily: 'Metropolis'),
home: const HomePage());
});
}

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/services/apps_provider.dart';
import 'package:obtainium/services/source_service.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 AddAppPage extends StatefulWidget {
@ -52,13 +53,16 @@ class _AddAppPageState extends State<AddAppPage> {
setState(() {
gettingAppInfo = true;
});
SourceService()
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(
@ -67,6 +71,7 @@ class _AddAppPageState extends State<AddAppPage> {
builder: (context) =>
AppPage(appId: app.id)));
});
});
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:obtainium/services/apps_provider.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart';
@ -26,6 +26,7 @@ class _AppPageState extends State<AppPage> {
),
body: WebView(
initialUrl: app?.app.url,
javascriptMode: JavascriptMode.unrestricted,
),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(
@ -48,7 +49,10 @@ class _AppPageState extends State<AppPage> {
? () {
appsProvider
.downloadAndInstallLatestApp(
app!.app.id);
[app!.app.id],
context).then((_) {
Navigator.of(context).pop();
});
}
: null,
child: Text(app?.app.installedVersion == null

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:obtainium/pages/app.dart';
import 'package:obtainium/services/apps_provider.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:provider/provider.dart';
class AppsPage extends StatefulWidget {
@ -14,7 +15,6 @@ class _AppsPageState extends State<AppsPage> {
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
appsProvider.checkUpdates();
var existingUpdateAppIds = appsProvider.getExistingUpdates();
return Scaffold(
@ -26,9 +26,13 @@ class _AppsPageState extends State<AppsPage> {
.isNotEmpty
? null
: () {
for (var e in existingUpdateAppIds) {
appsProvider.downloadAndInstallLatestApp(e);
}
context
.read<SettingsProvider>()
.getInstallPermission()
.then((_) {
appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context);
});
},
icon: const Icon(Icons.update),
label: const Text('Update All')),

View File

@ -1,5 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:obtainium/services/settings_provider.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';
@ -13,6 +16,7 @@ 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();
@ -66,17 +70,167 @@ class _SettingsPageState extends State<SettingsPage> {
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
: () {
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Exported to $path')),
);
});
},
child: const Text('Export Apps')),
ElevatedButton(
onPressed: () {
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: () {
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')),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'))
],
);
});
},
child: const Text('Import Apps'))
],
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
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: const Text('Source'),
label: Text(
'Source',
style: Theme.of(context).textTheme.caption,
),
)
],
),

View File

@ -1,15 +1,16 @@
// Provider that manages App-related state and provides functions to retrieve App info download/install Apps
// Manages state related to the list of Apps tracked by Obtainium,
// Exposes related functions such as those used to add, remove, download, and install Apps.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:obtainium/providers/notifications_provider.dart';
import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/services/source_service.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:http/http.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
@ -20,58 +21,36 @@ class AppInMemory {
AppInMemory(this.app, this.downloadProgress);
}
class ApkFile {
String appId;
File file;
ApkFile(this.appId, this.file);
}
class AppsProvider with ChangeNotifier {
// In memory App state (should always be kept in sync with local storage versions)
Map<String, AppInMemory> apps = {};
bool loadingApps = false;
bool gettingUpdates = false;
// Notifications plugin for downloads
FlutterLocalNotificationsPlugin downloaderNotifications =
FlutterLocalNotificationsPlugin();
// Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true;
StreamSubscription<FGBGType>? foregroundSubscription;
late Stream<FGBGType> foregroundStream;
late StreamSubscription<FGBGType> foregroundSubscription;
AppsProvider({bool bg = false}) {
initializeNotifs();
// Subscribe to changes in the app foreground status
foregroundSubscription = FGBGEvents.stream.listen((event) async {
foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundSubscription = foregroundStream.listen((event) async {
isForeground = event == FGBGType.foreground;
if (isForeground) await loadApps();
});
loadApps();
}
Future<void> initializeNotifs() async {
// Initialize the notifications service
await downloaderNotifications.initialize(const InitializationSettings(
android: AndroidInitializationSettings('ic_notification')));
}
Future<void> notify(int id, String title, String message, String channelCode,
String channelName, String channelDescription,
{bool important = true}) {
return downloaderNotifications.show(
id,
title,
message,
NotificationDetails(
android: AndroidNotificationDetails(channelCode, channelName,
channelDescription: channelDescription,
importance: important ? Importance.max : Importance.min,
priority: important ? Priority.max : Priority.min,
groupKey: 'dev.imranr.obtainium.$channelCode')));
}
// Given a App (assumed valid), initiate an APK download (will trigger install callback when complete)
Future<void> downloadAndInstallLatestApp(String appId) async {
if (apps[appId] == null) {
throw 'App not found';
}
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apps[appId]!.app.apkUrl)));
await Client().send(Request('GET', Uri.parse(apkUrl)));
File downloadFile =
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
if (downloadFile.existsSync()) {
@ -97,30 +76,74 @@ class AppsProvider with ChangeNotifier {
downloadFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
}
return ApkFile(appId, downloadFile);
}
// 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(
List<String> appIds, BuildContext context) async {
NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>();
Map<String, String> appsToInstall = {};
for (var id in appIds) {
if (apps[id] == null) {
throw 'App not found';
}
String apkUrl = apps[id]!.app.apkUrls.last;
if (apps[id]!.app.apkUrls.length > 1) {
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'))
],
);
});
}
appsToInstall.putIfAbsent(id, () => apkUrl);
}
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
.map((entry) => downloadApp(entry.value, entry.key)));
if (!isForeground) {
await downloaderNotifications.cancel(1);
await notify(
1,
'Complete App Installation',
'Obtainium must be open to install Apps',
'COMPLETE_INSTALL',
'Complete App Installation',
'Asks the user to return to Obtanium to finish installing an App');
while (await FGBGEvents.stream.first != FGBGType.foreground) {
await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true);
await FGBGEvents.stream.first == FGBGType.foreground;
// 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
}
}
// Unfortunately this 'await' does not actually wait for the APK to finish installing
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
await InstallPlugin.installApk(downloadFile.path, 'dev.imranr.obtainium');
apps[appId]!.app.installedVersion = apps[appId]!.app.latestVersion;
saveApp(apps[appId]!.app);
for (var f in downloadedFiles) {
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium');
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
await saveApp(apps[f.appId]!.app);
}
}
Future<Directory> getAppsDir() async {
@ -186,7 +209,7 @@ class AppsProvider with ChangeNotifier {
Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]!.app;
App newApp = await SourceService().getApp(currentApp.url);
App newApp = await sourceProvider().getApp(currentApp.url);
if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion;
await saveApp(newApp);
@ -224,10 +247,37 @@ class AppsProvider with ChangeNotifier {
return updateAppIds;
}
Future<String> exportApps() async {
Directory? exportDir = Directory('/storage/emulated/0/Download');
String path = 'Downloads';
if (!exportDir.existsSync()) {
exportDir = await getExternalStorageDirectory();
path = exportDir!.path;
}
File export = File(
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
export.writeAsStringSync(
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
return path;
}
Future<int> importApps(String appsJSON) async {
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
.map((e) => App.fromJson(e))
.toList();
for (App a in importedApps) {
a.installedVersion =
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
await saveApp(a);
}
notifyListeners();
return importedApps.length;
}
@override
void dispose() {
IsolateNameServer.removePortNameMapping('downloader_send_port');
foregroundSubscription?.cancel();
foregroundSubscription.cancel();
super.dispose();
}
}

View File

@ -0,0 +1,127 @@
// Exposes functions that can be used to send notifications to the user
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/providers/source_provider.dart';
class ObtainiumNotification {
late int id;
late String title;
late String message;
late String channelCode;
late String channelName;
late String channelDescription;
Importance importance;
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
this.channelName, this.channelDescription, this.importance);
}
class UpdateNotification extends ObtainiumNotification {
UpdateNotification(List<App> updates)
: super(
2,
'Updates Available',
'',
'UPDATES_AVAILABLE',
'Updates Available',
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
Importance.max) {
message = updates.length == 1
? '${updates[0].name} has an update.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
}
}
class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error)
: super(
5,
'Error Checking for Updates',
error,
'BG_UPDATE_CHECK_ERROR',
'Error Checking for Updates',
'A notification that shows when background update checking fails',
Importance.high);
}
final completeInstallationNotification = ObtainiumNotification(
1,
'Complete App Installation',
'Obtainium must be open to install Apps',
'COMPLETE_INSTALL',
'Complete App Installation',
'Asks the user to return to Obtanium to finish installing an App',
Importance.max);
final checkingUpdatesNotification = ObtainiumNotification(
4,
'Checking for Updates',
'',
'BG_UPDATE_CHECK',
'Checking for Updates',
'Transient notification that appears when checking for updates',
Importance.min);
class NotificationsProvider {
FlutterLocalNotificationsPlugin notifications =
FlutterLocalNotificationsPlugin();
bool isInitialized = false;
Map<Importance, Priority> importanceToPriority = {
Importance.defaultImportance: Priority.defaultPriority,
Importance.high: Priority.high,
Importance.low: Priority.low,
Importance.max: Priority.max,
Importance.min: Priority.min,
Importance.none: Priority.min,
Importance.unspecified: Priority.defaultPriority
};
Future<void> initialize() async {
isInitialized = await notifications.initialize(const InitializationSettings(
android: AndroidInitializationSettings('ic_notification'))) ??
false;
}
Future<void> cancel(int id) async {
if (!isInitialized) {
await initialize();
}
await notifications.cancel(id);
}
Future<void> notifyRaw(
int id,
String title,
String message,
String channelCode,
String channelName,
String channelDescription,
Importance importance,
{bool cancelExisting = false}) async {
if (cancelExisting) {
await cancel(id);
}
if (!isInitialized) {
await initialize();
}
await notifications.show(
id,
title,
message,
NotificationDetails(
android: AndroidNotificationDetails(channelCode, channelName,
channelDescription: channelDescription,
importance: importance,
priority: importanceToPriority[importance]!,
groupKey: 'dev.imranr.obtainium.$channelCode')));
}
Future<void> notify(ObtainiumNotification notif,
{bool cancelExisting = false}) =>
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
notif.channelName, notif.channelDescription, notif.importance,
cancelExisting: cancelExisting);
}

View File

@ -1,4 +1,8 @@
// Exposes functions used to save/load app settings
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum ThemeSettings { system, light, dark }
@ -22,7 +26,6 @@ class SettingsProvider with ChangeNotifier {
}
set theme(ThemeSettings t) {
print(t);
prefs?.setInt('theme', t.index);
notifyListeners();
}
@ -37,11 +40,33 @@ class SettingsProvider with ChangeNotifier {
notifyListeners();
}
checkAndFlipFirstRun() {
int get updateInterval {
return prefs?.getInt('updateInterval') ?? 1440;
}
set updateInterval(int min) {
prefs?.setInt('updateInterval', min < 15 ? 15 : min);
notifyListeners();
}
bool checkAndFlipFirstRun() {
bool result = prefs?.getBool('firstRun') ?? true;
if (result) {
prefs?.setBool('firstRun', false);
}
return result;
}
Future<void> getInstallPermission() async {
while (!(await Permission.requestInstallPackages.isGranted)) {
// Explicit request as InstallPlugin request sometimes bugged
Fluttertoast.showToast(
msg: 'Please allow Obtainium to install Apps',
toastLength: Toast.LENGTH_LONG);
if ((await Permission.requestInstallPackages.request()) ==
PermissionStatus.granted) {
break;
}
}
}
}

View File

@ -0,0 +1,230 @@
// Defines App sources and provides functions used to interact with them
// AppSource is an abstract class with a concrete implementation for each source
import 'dart:convert';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:html/parser.dart';
class AppNames {
late String author;
late String name;
AppNames(this.author, this.name);
}
class APKDetails {
late String version;
late List<String> apkUrls;
APKDetails(this.version, this.apkUrls);
}
class App {
late String id;
late String url;
late String author;
late String name;
String? installedVersion;
late String latestVersion;
List<String> apkUrls = [];
App(this.id, this.url, this.author, this.name, this.installedVersion,
this.latestVersion, this.apkUrls);
@override
String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrls';
}
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'])));
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'author': author,
'name': name,
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls),
};
}
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}";
});
}
List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) =>
dom
.querySelectorAll('a')
.where((element) {
if (element.attributes['href'] == null) return false;
return hrefPattern.hasMatch(element.attributes['href']!);
})
.map((e) => '$prependToLinks${e.attributes['href']!}')
.toList();
abstract class AppSource {
late String sourceId;
String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl);
}
class GitHub implements AppSource {
@override
String sourceId = 'github';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
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 {
// 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);
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
String sourceId = 'gitlab';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://gitlab.com/[^/]*/[^/]*');
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 {
// 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);
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 sourceProvider {
// 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();
}
throw 'URL does not match a known source';
}
Future<App> getApp(String url) async {
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)}';
}
AppSource source = getSource(url);
String standardUrl = source.standardizeURL(url);
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.sourceId}',
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version,
apk.apkUrls);
}
}

View File

@ -1,166 +0,0 @@
// Exposes functions related to interacting with App sources and retrieving App info
// Stateless - not a provider
import 'package:http/http.dart';
import 'package:html/parser.dart';
// Sub-classes used in App Source
class AppNames {
late String author;
late String name;
AppNames(this.author, this.name);
}
class APKDetails {
late String version;
late String downloadUrl;
APKDetails(this.version, this.downloadUrl);
}
// App Source abstract class (diff. implementations for GitHub, GitLab, etc.)
abstract class AppSource {
late String sourceId;
String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl);
}
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}";
});
}
// App class
class App {
late String id;
late String url;
late String author;
late String name;
String? installedVersion;
late String latestVersion;
late String apkUrl;
App(this.id, this.url, this.author, this.name, this.installedVersion,
this.latestVersion, this.apkUrl);
@override
String toString() {
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrl';
}
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,
json['apkUrl'] as String);
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'author': author,
'name': name,
'installedVersion': installedVersion,
'latestVersion': latestVersion,
'apkUrl': apkUrl,
};
}
// Specific App Source classes
class GitHub implements AppSource {
@override
String sourceId = 'github';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw 'Not a valid URL';
}
return url.substring(0, match.end);
}
String convertURL(String url, String replaceText) {
int tempInd1 = url.indexOf('://') + 3;
int tempInd2 = url.substring(tempInd1).indexOf('/') + tempInd1;
return '${url.substring(0, tempInd1)}$replaceText${url.substring(tempInd2)}';
}
@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 = parsedHtml.querySelectorAll('a').where((element) {
return RegExp(
'^${escapeRegEx(standardUri.path)}/releases/download/*/(?!/).*.apk\$',
caseSensitive: false)
.hasMatch(element.attributes['href']!);
}).toList();
String? version = parsedHtml
.querySelector('.octicon-tag')
?.nextElementSibling
?.innerHtml
.trim();
if (apkUrlList.isEmpty || version == null) {
throw 'No APK found';
}
return APKDetails(
version, '${standardUri.origin}${apkUrlList[0].attributes['href']!}');
} 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 SourceService {
// Add more source classes here so they are available via the service
AppSource github = GitHub();
AppSource getSource(String url) {
if (url.toLowerCase().contains('://github.com')) {
return github;
}
throw 'URL does not match a known source';
}
Future<App> getApp(String url) async {
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)}';
}
AppSource source = getSource(url);
String standardUrl = source.standardizeURL(url);
AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
return App(
'${names.author}_${names.name}_${source.sourceId}',
standardUrl,
names.author[0].toUpperCase() + names.author.substring(1),
names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version,
apk.downloadUrl);
}
}

View File

@ -177,6 +177,13 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.9"
html:
dependency: "direct main"
description:
@ -317,6 +324,41 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
url: "https://pub.dartlang.org"
source: hosted
version: "9.0.4"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
petitparser:
dependency: transitive
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.2+3 # When changing this, update the tag in main() accordingly
version: 0.1.4+5 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -48,6 +48,8 @@ dependencies:
html: ^0.15.0
shared_preferences: ^2.0.15
url_launcher: ^6.1.5
permission_handler: ^10.0.0
fluttertoast: ^8.0.9
dev_dependencies:

BIN
screenshots/1.apps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshots/4.app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB