mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 05:16:43 +02:00
Compare commits
14 Commits
v0.1.1-bet
...
v0.1.3-bet
Author | SHA1 | Date | |
---|---|---|---|
7f1fd3c6c0 | |||
209f7ea516 | |||
09791979d5 | |||
e7170aca48 | |||
7932b909c0 | |||
4c4a9093e4 | |||
a6f290eb59 | |||
ecb1e7d367 | |||
10f1c3abe5 | |||
9459c96d48 | |||
2aca9d680b | |||
bd205dadc5 | |||
21ca18ce75 | |||
7afcf6a37b |
14
README.md
14
README.md
@ -1,4 +1,4 @@
|
||||
# Obtainium
|
||||
#  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.
|
||||
- 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" /> |
|
||||
|
@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion flutter.compileSdkVersion
|
||||
compileSdkVersion 33
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@ -54,7 +54,7 @@ android {
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 32
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
3
android/app/src/main/res/raw/keep.xml
Normal file
3
android/app/src/main/res/raw/keep.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/*" />
|
129
lib/main.dart
129
lib/main.dart
@ -1,55 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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.3-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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -61,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(),
|
||||
));
|
||||
@ -85,40 +65,40 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
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();
|
||||
});
|
||||
} 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) {
|
||||
// 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',
|
||||
currentReleaseTag,
|
||||
currentReleaseTag, []));
|
||||
}
|
||||
}
|
||||
|
||||
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>();
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings().then((_) {
|
||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||
if (isFirstRun) {
|
||||
appsProvider
|
||||
.notify(
|
||||
3,
|
||||
'Permission Notification',
|
||||
'This is a transient notification used to trigger the Android 13 notification permission prompt',
|
||||
'PERMISSION_NOTIFICATION',
|
||||
'Permission Notifications',
|
||||
'A transient notification used to trigger the Android 13 notification permission prompt',
|
||||
important: false)
|
||||
.whenComplete(() {
|
||||
appsProvider.downloaderNotifications.cancel(3);
|
||||
});
|
||||
appsProvider.saveApp(App(
|
||||
'imranr98_obtainium_github',
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
'v0.1.1-beta', // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
'v0.1.1-beta',
|
||||
''));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Decide on a colour/brightness scheme based on OS and user settings
|
||||
ColorScheme lightColorScheme;
|
||||
ColorScheme darkColorScheme;
|
||||
if (lightDynamic != null &&
|
||||
@ -131,7 +111,6 @@ class MyApp extends StatelessWidget {
|
||||
darkColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: defaultThemeColour, brightness: Brightness.dark);
|
||||
}
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Obtainium',
|
||||
theme: ThemeData(
|
||||
|
@ -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,20 +53,24 @@ 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';
|
||||
}
|
||||
appsProvider.saveApp(app).then((_) {
|
||||
urlInputController.clear();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: app.id)));
|
||||
settingsProvider.getInstallPermission().then((_) {
|
||||
appsProvider.saveApp(app).then((_) {
|
||||
urlInputController.clear();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: app.id)));
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
@ -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,7 @@ class _AppPageState extends State<AppPage> {
|
||||
? () {
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApp(
|
||||
app!.app.id);
|
||||
[app!.app.id], context);
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
|
@ -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')),
|
||||
|
@ -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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -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) {
|
||||
// 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
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
127
lib/providers/notifications_provider.dart
Normal file
127
lib/providers/notifications_provider.dart
Normal 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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
230
lib/providers/source_provider.dart
Normal file
230
lib/providers/source_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
54
pubspec.lock
54
pubspec.lock
@ -91,7 +91,7 @@ packages:
|
||||
name: dbus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.8"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -119,7 +119,7 @@ packages:
|
||||
name: file
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.2"
|
||||
version: "6.1.4"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -152,7 +152,7 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.7.0"
|
||||
version: "9.8.0+1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -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:
|
||||
@ -281,7 +288,7 @@ packages:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.17"
|
||||
version: "2.0.20"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
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:
|
||||
@ -552,7 +594,7 @@ packages:
|
||||
name: webview_flutter_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
version: "1.9.2"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -580,7 +622,7 @@ packages:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0+1"
|
||||
version: "0.2.0+2"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -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.1+2 # When changing this, update the tag in main() accordingly
|
||||
version: 0.1.3+4 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||
@ -38,7 +38,7 @@ dependencies:
|
||||
cupertino_icons: ^1.0.2
|
||||
path_provider: ^2.0.11
|
||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||
flutter_local_notifications: ^9.7.0
|
||||
flutter_local_notifications: ^9.8.0+1
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
@ -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
BIN
screenshots/1.apps.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 99 KiB |
BIN
screenshots/2.dark_theme.png
Normal file
BIN
screenshots/2.dark_theme.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/3.material_you.png
Normal file
BIN
screenshots/3.material_you.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 86 KiB |
BIN
screenshots/4.app.png
Normal file
BIN
screenshots/4.app.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 263 KiB |
BIN
screenshots/5.apk_picker.png
Normal file
BIN
screenshots/5.apk_picker.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 200 KiB |
BIN
screenshots/6.apk_install.png
Normal file
BIN
screenshots/6.apk_install.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 192 KiB |
Reference in New Issue
Block a user