Compare commits
	
		
			52 Commits
		
	
	
		
			v0.1.0-bet
			...
			v0.2.1-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 9e21f2d6e6 | ||
|  | 6f11f850e0 | ||
|  | 5e96b91029 | ||
|  | 5fc79af960 | ||
|  | 05f5590e7d | ||
|  | 50f8caeb47 | ||
|  | f966a9e626 | ||
|  | 02a5749ba7 | ||
|  | 4ccf7cbc92 | ||
|  | ab4efd85ce | ||
|  | 42bba0f64c | ||
|  | 294327bde4 | ||
|  | 52b97662c6 | ||
|  | f63da4b538 | ||
|  | c30c692d87 | ||
|  | d643d5a474 | ||
|  | f8101a5d9f | ||
|  | c2a7e4a0d2 | ||
|  | 285da7545b | ||
|  | a5230acc11 | ||
|  | 53019818a6 | ||
|  | 1a04d39144 | ||
|  | 96c1ed612d | ||
|  | 4d75a6a361 | ||
|  | 30075add1c | ||
|  | 52b4e1fb96 | ||
|  | f9044e20f1 | ||
|  | 7e5affe1b8 | ||
|  | 5bdab1b1e4 | ||
|  | c14c4d2f14 | ||
|  | 5e785ae1d5 | ||
|  | 6c076751ab | ||
|  | 4253203dca | ||
|  | 7f1fd3c6c0 | ||
|  | 209f7ea516 | ||
|  | 09791979d5 | ||
|  | e7170aca48 | ||
|  | 7932b909c0 | ||
|  | 4c4a9093e4 | ||
|  | a6f290eb59 | ||
|  | ecb1e7d367 | ||
|  | 10f1c3abe5 | ||
|  | 9459c96d48 | ||
|  | 2aca9d680b | ||
|  | bd205dadc5 | ||
|  | 21ca18ce75 | ||
|  | 7afcf6a37b | ||
|  | 9dba372244 | ||
|  | 88b60fe362 | ||
|  | 0362cdf8ac | ||
|  | aeada9635d | ||
|  | ffe212ebf2 | 
							
								
								
									
										20
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,17 +1,25 @@ | |||||||
| # Obtainium | #  Obtainium | ||||||
|  |  | ||||||
| Get Android App Updates Directly From the Source. | Get Android App Updates Directly From the Source. | ||||||
|  |  | ||||||
| Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | ||||||
|  |  | ||||||
| Currently supported App sources: |  | ||||||
| - GitHub |  | ||||||
|  |  | ||||||
| Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) | 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.*** | Currently supported App sources: | ||||||
|  | - [GitHub](https://github.com/) | ||||||
|  | - [GitLab](https://gitlab.com/) | ||||||
|  | - [F-Droid](https://f-droid.org/) | ||||||
|  | - [Mullvad](https://mullvad.net/en/) | ||||||
|  | - [Signal](https://signal.org/) | ||||||
|  |  | ||||||
| ## Limitations | ## Limitations | ||||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | - 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. | ||||||
|  | - For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable. | ||||||
|  |  | ||||||
|  | ## Screenshots | ||||||
|  |  | ||||||
|  | | <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" />           | <img src="./assets/screenshots/3.material_you.png" alt="Material You" />    | | ||||||
|  | | ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | ||||||
|  | | <img src="./assets/screenshots/4.app.png" alt="App Page" />   | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> | | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) { | |||||||
| } | } | ||||||
|  |  | ||||||
| android { | android { | ||||||
|     compileSdkVersion flutter.compileSdkVersion |     compileSdkVersion 33 | ||||||
|     ndkVersion flutter.ndkVersion |     ndkVersion flutter.ndkVersion | ||||||
|  |  | ||||||
|     compileOptions { |     compileOptions { | ||||||
| @@ -54,7 +54,7 @@ android { | |||||||
|         // You can update the following values to match your application needs. |         // 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. |         // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. | ||||||
|         minSdkVersion 23 |         minSdkVersion 23 | ||||||
|         targetSdkVersion 32 |         targetSdkVersion 33 | ||||||
|         versionCode flutterVersionCode.toInteger() |         versionCode flutterVersionCode.toInteger() | ||||||
|         versionName flutterVersionName |         versionName flutterVersionName | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable/ic_notification.png
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.0 KiB | 
							
								
								
									
										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/*" /> | ||||||
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/banner.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 66 KiB | 
| Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/icon.psd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/obtainium.psd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/store-icon.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/1.apps.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 99 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/2.dark_theme.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 83 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/3.material_you.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 86 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/4.app.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 263 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/5.apk_picker.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 200 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/6.apk_install.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 192 KiB | 
							
								
								
									
										81
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,81 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  |  | ||||||
|  | class GeneratedFormItem { | ||||||
|  |   late String message; | ||||||
|  |   late bool required; | ||||||
|  |   late int lines; | ||||||
|  |  | ||||||
|  |   GeneratedFormItem(this.message, this.required, this.lines); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class GeneratedFormModal extends StatefulWidget { | ||||||
|  |   const GeneratedFormModal( | ||||||
|  |       {super.key, required this.title, required this.items}); | ||||||
|  |  | ||||||
|  |   final String title; | ||||||
|  |   final List<GeneratedFormItem> items; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||||
|  |   final _formKey = GlobalKey<FormState>(); | ||||||
|  |  | ||||||
|  |   final urlInputController = TextEditingController(); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final formInputs = widget.items.map((e) { | ||||||
|  |       final controller = TextEditingController(); | ||||||
|  |       return [ | ||||||
|  |         controller, | ||||||
|  |         TextFormField( | ||||||
|  |           decoration: InputDecoration(helperText: e.message), | ||||||
|  |           controller: controller, | ||||||
|  |           minLines: e.lines <= 1 ? null : e.lines, | ||||||
|  |           maxLines: e.lines <= 1 ? 1 : e.lines, | ||||||
|  |           validator: e.required | ||||||
|  |               ? (value) { | ||||||
|  |                   if (value == null || value.isEmpty) { | ||||||
|  |                     return '${e.message} (required)'; | ||||||
|  |                   } | ||||||
|  |                   return null; | ||||||
|  |                 } | ||||||
|  |               : null, | ||||||
|  |         ) | ||||||
|  |       ]; | ||||||
|  |     }).toList(); | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: Text(widget.title), | ||||||
|  |       content: Form( | ||||||
|  |           key: _formKey, | ||||||
|  |           child: Column( | ||||||
|  |             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |             children: [...formInputs.map((e) => e[1] as Widget)], | ||||||
|  |           )), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.lightImpact(); | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: const Text('Cancel')), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               if (_formKey.currentState?.validate() == true) { | ||||||
|  |                 HapticFeedback.heavyImpact(); | ||||||
|  |                 Navigator.of(context).pop(formInputs | ||||||
|  |                     .map((e) => (e[0] as TextEditingController).value.text) | ||||||
|  |                     .toList()); | ||||||
|  |               } | ||||||
|  |             }, | ||||||
|  |             child: const Text('Continue')) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // TODO: Add support for larger textarea so this can be used for text/json imports | ||||||
							
								
								
									
										141
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						| @@ -1,54 +1,67 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/home.dart'; | import 'package:obtainium/pages/home.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/services/settings_provider.dart'; | import 'package:obtainium/providers/notifications_provider.dart'; | ||||||
| import 'package:obtainium/services/source_service.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:provider/provider.dart'; | ||||||
| import 'package:workmanager/workmanager.dart'; | import 'package:workmanager/workmanager.dart'; | ||||||
| import 'package:dynamic_color/dynamic_color.dart'; | import 'package:dynamic_color/dynamic_color.dart'; | ||||||
|  | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
|  |  | ||||||
| void backgroundUpdateCheck() { | const String currentReleaseTag = | ||||||
|   Workmanager().executeTask((task, inputData) async { |     'v0.2.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||||
|     var appsProvider = AppsProvider(bg: true); |  | ||||||
|     await appsProvider.loadApps(); | @pragma('vm:entry-point') | ||||||
|     List<App> updates = await appsProvider.getUpdates(); | void bgTaskCallback() { | ||||||
|     if (updates.isNotEmpty) { |   // Background update checking process | ||||||
|       String message = updates.length == 1 |   Workmanager().executeTask((task, taskName) async { | ||||||
|           ? '${updates[0].name} has an update.' |     var notificationsProvider = NotificationsProvider(); | ||||||
|           : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; |     await notificationsProvider.notify(checkingUpdatesNotification); | ||||||
|       await appsProvider.downloaderNotifications.cancel(2); |     try { | ||||||
|       await appsProvider.notify( |       var appsProvider = AppsProvider(); | ||||||
|           2, |       await notificationsProvider | ||||||
|           'Updates Available', |           .cancel(ErrorCheckingUpdatesNotification('').id); | ||||||
|           message, |       await appsProvider.loadApps(); | ||||||
|           'UPDATES_AVAILABLE', |       List<App> updates = await appsProvider.checkUpdates(); | ||||||
|           'Updates Available', |       if (updates.isNotEmpty) { | ||||||
|           '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) { | ||||||
|  |       notificationsProvider.notify( | ||||||
|  |           ErrorCheckingUpdatesNotification(e.toString()), | ||||||
|  |           cancelExisting: true); | ||||||
|  |       return Future.value(false); | ||||||
|  |     } finally { | ||||||
|  |       await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||||
|     } |     } | ||||||
|     return Future.value(true); |  | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|   SystemChrome.setSystemUIOverlayStyle( |   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { | ||||||
|     const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), |     SystemChrome.setSystemUIOverlayStyle( | ||||||
|   ); |       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), | ||||||
|   SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); |     ); | ||||||
|  |     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||||
|  |   } | ||||||
|   Workmanager().initialize( |   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( |   runApp(MultiProvider( | ||||||
|     providers: [ |     providers: [ | ||||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), |       ChangeNotifierProvider( | ||||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()) |           create: (context) => AppsProvider( | ||||||
|  |               shouldLoadApps: true, | ||||||
|  |               shouldCheckUpdatesAfterLoad: true, | ||||||
|  |               shouldDeleteAPKs: true)), | ||||||
|  |       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||||
|  |       Provider(create: (context) => NotificationsProvider()) | ||||||
|     ], |     ], | ||||||
|     child: const MyApp(), |     child: const MyApp(), | ||||||
|   )); |   )); | ||||||
| @@ -61,31 +74,41 @@ class MyApp extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|  |     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||||
|  |  | ||||||
|  |     if (settingsProvider.prefs == null) { | ||||||
|  |       settingsProvider.initializeSettings(); | ||||||
|  |     } else { | ||||||
|  |       // Register the background update task according to the user's setting | ||||||
|  |       if (settingsProvider.updateInterval > 0) { | ||||||
|  |         Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check', | ||||||
|  |             frequency: Duration(minutes: settingsProvider.updateInterval), | ||||||
|  |             initialDelay: Duration(minutes: settingsProvider.updateInterval), | ||||||
|  |             constraints: Constraints(networkType: NetworkType.connected), | ||||||
|  |             existingWorkPolicy: ExistingWorkPolicy.replace); | ||||||
|  |       } else { | ||||||
|  |         Workmanager().cancelByUniqueName('bg-update-check'); | ||||||
|  |       } | ||||||
|  |       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); | ||||||
|  |       if (isFirstRun) { | ||||||
|  |         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||||
|  |         Permission.notification.request(); | ||||||
|  |         appsProvider.saveApp(App( | ||||||
|  |             'imranr98_obtainium_${GitHub().host}', | ||||||
|  |             'https://github.com/ImranR98/Obtainium', | ||||||
|  |             'ImranR98', | ||||||
|  |             'Obtainium', | ||||||
|  |             currentReleaseTag, | ||||||
|  |             currentReleaseTag, | ||||||
|  |             [], | ||||||
|  |             0)); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return DynamicColorBuilder( |     return DynamicColorBuilder( | ||||||
|         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { |         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { | ||||||
|       // Initialize the settings provider (if needed) and perform first-run actions if needed |       // Decide on a colour/brightness scheme based on OS and user settings | ||||||
|       SettingsProvider settingsProvider = context.watch<SettingsProvider>(); |  | ||||||
|       if (settingsProvider.prefs == null) { |  | ||||||
|         settingsProvider.initializeSettings().then((_) { |  | ||||||
|           bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); |  | ||||||
|           if (isFirstRun) { |  | ||||||
|             AppsProvider appsProvider = context.read<AppsProvider>(); |  | ||||||
|             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); |  | ||||||
|             }); |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       ColorScheme lightColorScheme; |       ColorScheme lightColorScheme; | ||||||
|       ColorScheme darkColorScheme; |       ColorScheme darkColorScheme; | ||||||
|       if (lightDynamic != null && |       if (lightDynamic != null && | ||||||
| @@ -98,7 +121,6 @@ class MyApp extends StatelessWidget { | |||||||
|         darkColorScheme = ColorScheme.fromSeed( |         darkColorScheme = ColorScheme.fromSeed( | ||||||
|             seedColor: defaultThemeColour, brightness: Brightness.dark); |             seedColor: defaultThemeColour, brightness: Brightness.dark); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       return MaterialApp( |       return MaterialApp( | ||||||
|           title: 'Obtainium', |           title: 'Obtainium', | ||||||
|           theme: ThemeData( |           theme: ThemeData( | ||||||
| @@ -111,7 +133,8 @@ class MyApp extends StatelessWidget { | |||||||
|               useMaterial3: true, |               useMaterial3: true, | ||||||
|               colorScheme: settingsProvider.theme == ThemeSettings.light |               colorScheme: settingsProvider.theme == ThemeSettings.light | ||||||
|                   ? lightColorScheme |                   ? lightColorScheme | ||||||
|                   : darkColorScheme), |                   : darkColorScheme, | ||||||
|  |               fontFamily: 'Metropolis'), | ||||||
|           home: const HomePage()); |           home: const HomePage()); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,8 +1,11 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/app.dart'; | import 'package:obtainium/pages/app.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/services/source_service.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class AddAppPage extends StatefulWidget { | class AddAppPage extends StatefulWidget { | ||||||
|   const AddAppPage({super.key}); |   const AddAppPage({super.key}); | ||||||
| @@ -18,73 +21,114 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|  |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|     return Center( |     return Center( | ||||||
|         child: Form( |       child: Form( | ||||||
|       key: _formKey, |           key: _formKey, | ||||||
|       child: Column( |           child: Column( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.stretch, |             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|         children: [ |             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|           const Spacer(), |             children: [ | ||||||
|           Padding( |               Container(), | ||||||
|               padding: const EdgeInsets.symmetric(horizontal: 16.0), |               Padding( | ||||||
|               child: TextFormField( |                 padding: const EdgeInsets.all(16), | ||||||
|                 decoration: const InputDecoration( |                 child: Column( | ||||||
|                     hintText: 'https://github.com/Author/Project', |                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                     helperText: 'Enter the App source URL'), |                   children: [ | ||||||
|                 controller: urlInputController, |                     TextFormField( | ||||||
|                 validator: (value) { |                       decoration: const InputDecoration( | ||||||
|                   if (value == null || |                           hintText: 'https://github.com/Author/Project', | ||||||
|                       value.isEmpty || |                           helperText: 'Enter the App source URL'), | ||||||
|                       Uri.tryParse(value) == null) { |                       controller: urlInputController, | ||||||
|                     return 'Please enter a supported source URL'; |                       validator: (value) { | ||||||
|                   } |                         if (value == null || | ||||||
|                   return null; |                             value.isEmpty || | ||||||
|                 }, |                             Uri.tryParse(value) == null) { | ||||||
|               )), |                           return 'Please enter a supported source URL'; | ||||||
|           Padding( |                         } | ||||||
|             padding: |                         return null; | ||||||
|                 const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), |                       }, | ||||||
|             child: ElevatedButton( |                     ), | ||||||
|               onPressed: gettingAppInfo |                     Padding( | ||||||
|                   ? null |                       padding: const EdgeInsets.symmetric(vertical: 16.0), | ||||||
|                   : () { |                       child: ElevatedButton( | ||||||
|                       if (_formKey.currentState!.validate()) { |                         onPressed: gettingAppInfo | ||||||
|                         setState(() { |                             ? null | ||||||
|                           gettingAppInfo = true; |                             : () { | ||||||
|                         }); |                                 HapticFeedback.mediumImpact(); | ||||||
|                         SourceService() |                                 if (_formKey.currentState!.validate()) { | ||||||
|                             .getApp(urlInputController.value.text) |                                   setState(() { | ||||||
|                             .then((app) { |                                     gettingAppInfo = true; | ||||||
|                           var appsProvider = context.read<AppsProvider>(); |                                   }); | ||||||
|                           if (appsProvider.apps.containsKey(app.id)) { |                                   sourceProvider | ||||||
|                             throw 'App already added'; |                                       .getApp(urlInputController.value.text) | ||||||
|                           } |                                       .then((app) { | ||||||
|                           appsProvider.saveApp(app).then((_) { |                                     var appsProvider = | ||||||
|                             urlInputController.clear(); |                                         context.read<AppsProvider>(); | ||||||
|                             Navigator.push( |                                     var settingsProvider = | ||||||
|                                 context, |                                         context.read<SettingsProvider>(); | ||||||
|                                 MaterialPageRoute( |                                     if (appsProvider.apps.containsKey(app.id)) { | ||||||
|                                     builder: (context) => |                                       throw 'App already added'; | ||||||
|                                         AppPage(appId: app.id))); |                                     } | ||||||
|                           }); |                                     settingsProvider | ||||||
|                         }).catchError((e) { |                                         .getInstallPermission() | ||||||
|                           ScaffoldMessenger.of(context).showSnackBar( |                                         .then((_) { | ||||||
|                             SnackBar(content: Text(e.toString())), |                                       appsProvider.saveApp(app).then((_) { | ||||||
|                           ); |                                         urlInputController.clear(); | ||||||
|                         }).whenComplete(() { |                                         Navigator.push( | ||||||
|                           setState(() { |                                             context, | ||||||
|                             gettingAppInfo = false; |                                             MaterialPageRoute( | ||||||
|                           }); |                                                 builder: (context) => | ||||||
|                         }); |                                                     AppPage(appId: app.id))); | ||||||
|                       } |                                       }); | ||||||
|                     }, |                                     }); | ||||||
|               child: const Text('Add'), |                                   }).catchError((e) { | ||||||
|             ), |                                     ScaffoldMessenger.of(context).showSnackBar( | ||||||
|           ), |                                       SnackBar(content: Text(e.toString())), | ||||||
|           const Spacer(), |                                     ); | ||||||
|           if (gettingAppInfo) const LinearProgressIndicator(), |                                   }).whenComplete(() { | ||||||
|         ], |                                     setState(() { | ||||||
|       ), |                                       gettingAppInfo = false; | ||||||
|     )); |                                     }); | ||||||
|  |                                   }); | ||||||
|  |                                 } | ||||||
|  |                               }, | ||||||
|  |                         child: const Text('Add'), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |               Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ | ||||||
|  |                 const Text( | ||||||
|  |                   'Supported Sources:', | ||||||
|  |                   // style: TextStyle(fontWeight: FontWeight.bold), | ||||||
|  |                   // style: Theme.of(context).textTheme.bodySmall, | ||||||
|  |                 ), | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 8, | ||||||
|  |                 ), | ||||||
|  |                 ...sourceProvider | ||||||
|  |                     .getSourceHosts() | ||||||
|  |                     .map((e) => GestureDetector( | ||||||
|  |                         onTap: () { | ||||||
|  |                           launchUrlString('https://$e', | ||||||
|  |                               mode: LaunchMode.externalApplication); | ||||||
|  |                         }, | ||||||
|  |                         child: Text( | ||||||
|  |                           e, | ||||||
|  |                           style: const TextStyle( | ||||||
|  |                               decoration: TextDecoration.underline, | ||||||
|  |                               fontStyle: FontStyle.italic), | ||||||
|  |                         ))) | ||||||
|  |                     .toList() | ||||||
|  |               ]), | ||||||
|  |               if (gettingAppInfo) | ||||||
|  |                 const LinearProgressIndicator() | ||||||
|  |               else | ||||||
|  |                 Container(), | ||||||
|  |             ], | ||||||
|  |           )), | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,8 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
| import 'package:webview_flutter/webview_flutter.dart'; | import 'package:webview_flutter/webview_flutter.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
| @@ -16,6 +19,7 @@ class _AppPageState extends State<AppPage> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|  |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; |     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||||
|     if (app?.app.installedVersion != null) { |     if (app?.app.installedVersion != null) { | ||||||
|       appsProvider.getUpdate(app!.app.id); |       appsProvider.getUpdate(app!.app.id); | ||||||
| @@ -24,9 +28,58 @@ class _AppPageState extends State<AppPage> { | |||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: Text('${app?.app.author}/${app?.app.name}'), |         title: Text('${app?.app.author}/${app?.app.name}'), | ||||||
|       ), |       ), | ||||||
|       body: WebView( |       body: settingsProvider.showAppWebpage | ||||||
|         initialUrl: app?.app.url, |           ? WebView( | ||||||
|       ), |               initialUrl: app?.app.url, | ||||||
|  |               javascriptMode: JavascriptMode.unrestricted, | ||||||
|  |             ) | ||||||
|  |           : Column( | ||||||
|  |               mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 Text( | ||||||
|  |                   app?.app.name ?? 'App', | ||||||
|  |                   textAlign: TextAlign.center, | ||||||
|  |                   style: Theme.of(context).textTheme.displayLarge, | ||||||
|  |                 ), | ||||||
|  |                 Text( | ||||||
|  |                   'By ${app?.app.author ?? 'Unknown'}', | ||||||
|  |                   textAlign: TextAlign.center, | ||||||
|  |                   style: Theme.of(context).textTheme.headlineMedium, | ||||||
|  |                 ), | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 32, | ||||||
|  |                 ), | ||||||
|  |                 GestureDetector( | ||||||
|  |                     onTap: () { | ||||||
|  |                       if (app?.app.url != null) { | ||||||
|  |                         launchUrlString(app?.app.url ?? '', | ||||||
|  |                             mode: LaunchMode.externalApplication); | ||||||
|  |                       } | ||||||
|  |                     }, | ||||||
|  |                     child: Text( | ||||||
|  |                       app?.app.url ?? '', | ||||||
|  |                       textAlign: TextAlign.center, | ||||||
|  |                       style: const TextStyle( | ||||||
|  |                           decoration: TextDecoration.underline, | ||||||
|  |                           fontStyle: FontStyle.italic, | ||||||
|  |                           fontSize: 12), | ||||||
|  |                     )), | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 32, | ||||||
|  |                 ), | ||||||
|  |                 Text( | ||||||
|  |                   'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', | ||||||
|  |                   textAlign: TextAlign.center, | ||||||
|  |                   style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |                 ), | ||||||
|  |                 Text( | ||||||
|  |                   'Installed Version: ${app?.app.installedVersion ?? 'None'}', | ||||||
|  |                   textAlign: TextAlign.center, | ||||||
|  |                   style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|       bottomSheet: Padding( |       bottomSheet: Padding( | ||||||
|           padding: EdgeInsets.fromLTRB( |           padding: EdgeInsets.fromLTRB( | ||||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), |               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||||
| @@ -38,17 +91,59 @@ class _AppPageState extends State<AppPage> { | |||||||
|                   child: Row( |                   child: Row( | ||||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, |                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|                       children: [ |                       children: [ | ||||||
|  |                         if (app?.app.installedVersion == null) | ||||||
|  |                           IconButton( | ||||||
|  |                               onPressed: () { | ||||||
|  |                                 showDialog( | ||||||
|  |                                     context: context, | ||||||
|  |                                     builder: (BuildContext ctx) { | ||||||
|  |                                       return AlertDialog( | ||||||
|  |                                         title: const Text( | ||||||
|  |                                             'App Already Installed?'), | ||||||
|  |                                         actions: [ | ||||||
|  |                                           TextButton( | ||||||
|  |                                               onPressed: () { | ||||||
|  |                                                 Navigator.of(context).pop(); | ||||||
|  |                                               }, | ||||||
|  |                                               child: const Text('No')), | ||||||
|  |                                           TextButton( | ||||||
|  |                                               onPressed: () { | ||||||
|  |                                                 var updatedApp = app?.app; | ||||||
|  |                                                 if (updatedApp != null) { | ||||||
|  |                                                   updatedApp.installedVersion = | ||||||
|  |                                                       updatedApp.latestVersion; | ||||||
|  |                                                   appsProvider | ||||||
|  |                                                       .saveApp(updatedApp); | ||||||
|  |                                                 } | ||||||
|  |                                                 Navigator.of(context).pop(); | ||||||
|  |                                               }, | ||||||
|  |                                               child: const Text( | ||||||
|  |                                                   'Yes, Mark as Installed')) | ||||||
|  |                                         ], | ||||||
|  |                                       ); | ||||||
|  |                                     }); | ||||||
|  |                               }, | ||||||
|  |                               tooltip: 'Mark as Installed', | ||||||
|  |                               icon: const Icon(Icons.done)), | ||||||
|  |                         if (app?.app.installedVersion == null) | ||||||
|  |                           const SizedBox(width: 16.0), | ||||||
|                         Expanded( |                         Expanded( | ||||||
|                             child: ElevatedButton( |                             child: ElevatedButton( | ||||||
|                                 onPressed: (app?.app.installedVersion == null || |                                 onPressed: (app?.app.installedVersion == null || | ||||||
|                                             appsProvider |                                             appsProvider | ||||||
|                                                 .checkAppObjectForUpdate( |                                                 .checkAppObjectForUpdate( | ||||||
|                                                     app!.app)) && |                                                     app!.app)) && | ||||||
|                                         app?.downloadProgress == null |                                         !appsProvider.areDownloadsRunning() | ||||||
|                                     ? () { |                                     ? () { | ||||||
|  |                                         HapticFeedback.heavyImpact(); | ||||||
|                                         appsProvider |                                         appsProvider | ||||||
|                                             .downloadAndInstallLatestApp( |                                             .downloadAndInstallLatestApp( | ||||||
|                                                 app!.app.id); |                                                 [app!.app.id], | ||||||
|  |                                                 context).then((res) { | ||||||
|  |                                           if (res && mounted) { | ||||||
|  |                                             Navigator.of(context).pop(); | ||||||
|  |                                           } | ||||||
|  |                                         }); | ||||||
|                                       } |                                       } | ||||||
|                                     : null, |                                     : null, | ||||||
|                                 child: Text(app?.app.installedVersion == null |                                 child: Text(app?.app.installedVersion == null | ||||||
| @@ -59,6 +154,7 @@ class _AppPageState extends State<AppPage> { | |||||||
|                           onPressed: app?.downloadProgress != null |                           onPressed: app?.downloadProgress != null | ||||||
|                               ? null |                               ? null | ||||||
|                               : () { |                               : () { | ||||||
|  |                                   HapticFeedback.lightImpact(); | ||||||
|                                   showDialog( |                                   showDialog( | ||||||
|                                       context: context, |                                       context: context, | ||||||
|                                       builder: (BuildContext ctx) { |                                       builder: (BuildContext ctx) { | ||||||
| @@ -69,6 +165,7 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                           actions: [ |                                           actions: [ | ||||||
|                                             TextButton( |                                             TextButton( | ||||||
|                                                 onPressed: () { |                                                 onPressed: () { | ||||||
|  |                                                   HapticFeedback.heavyImpact(); | ||||||
|                                                   appsProvider |                                                   appsProvider | ||||||
|                                                       .removeApp(app!.app.id) |                                                       .removeApp(app!.app.id) | ||||||
|                                                       .then((_) { |                                                       .then((_) { | ||||||
| @@ -81,6 +178,7 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                                 child: const Text('Remove')), |                                                 child: const Text('Remove')), | ||||||
|                                             TextButton( |                                             TextButton( | ||||||
|                                                 onPressed: () { |                                                 onPressed: () { | ||||||
|  |                                                   HapticFeedback.lightImpact(); | ||||||
|                                                   Navigator.of(context).pop(); |                                                   Navigator.of(context).pop(); | ||||||
|                                                 }, |                                                 }, | ||||||
|                                                 child: const Text('Cancel')) |                                                 child: const Text('Cancel')) | ||||||
| @@ -89,8 +187,10 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                       }); |                                       }); | ||||||
|                                 }, |                                 }, | ||||||
|                           style: TextButton.styleFrom( |                           style: TextButton.styleFrom( | ||||||
|                               foregroundColor: Theme.of(context).errorColor, |                               foregroundColor: | ||||||
|                               surfaceTintColor: Theme.of(context).errorColor), |                                   Theme.of(context).colorScheme.error, | ||||||
|  |                               surfaceTintColor: | ||||||
|  |                                   Theme.of(context).colorScheme.error), | ||||||
|                           child: const Text('Remove'), |                           child: const Text('Remove'), | ||||||
|                         ), |                         ), | ||||||
|                       ])), |                       ])), | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/app.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'; | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
| class AppsPage extends StatefulWidget { | class AppsPage extends StatefulWidget { | ||||||
| @@ -14,46 +16,80 @@ class _AppsPageState extends State<AppsPage> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|     appsProvider.getUpdates(); |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     var existingUpdateAppIds = appsProvider.getExistingUpdates(); | ||||||
|  |     var sortedApps = appsProvider.apps.values.toList(); | ||||||
|  |     sortedApps.sort((a, b) { | ||||||
|  |       int result = 0; | ||||||
|  |       if (settingsProvider.sortColumn == SortColumnSettings.authorName) { | ||||||
|  |         result = | ||||||
|  |             (a.app.author + a.app.name).compareTo(b.app.author + b.app.name); | ||||||
|  |       } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { | ||||||
|  |         result = | ||||||
|  |             (a.app.name + a.app.author).compareTo(b.app.name + b.app.author); | ||||||
|  |       } | ||||||
|  |       return result; | ||||||
|  |     }); | ||||||
|  |     if (settingsProvider.sortOrder == SortOrderSettings.ascending) { | ||||||
|  |       sortedApps = sortedApps.reversed.toList(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return Center( |     return Scaffold( | ||||||
|       child: appsProvider.loadingApps |         floatingActionButton: existingUpdateAppIds.isEmpty | ||||||
|           ? const CircularProgressIndicator() |             ? null | ||||||
|           : appsProvider.apps.isEmpty |             : ElevatedButton.icon( | ||||||
|               ? Text( |                 onPressed: appsProvider.areDownloadsRunning() | ||||||
|                   'No Apps', |                     ? null | ||||||
|                   style: Theme.of(context).textTheme.headline4, |                     : () { | ||||||
|                 ) |                         HapticFeedback.heavyImpact(); | ||||||
|               : RefreshIndicator( |                         settingsProvider.getInstallPermission().then((_) { | ||||||
|                   onRefresh: appsProvider.getUpdates, |                           appsProvider.downloadAndInstallLatestApp( | ||||||
|                   child: ListView( |                               existingUpdateAppIds, context); | ||||||
|                     children: appsProvider.apps.values |                         }); | ||||||
|                         .map( |                       }, | ||||||
|                           (e) => ListTile( |                 icon: const Icon(Icons.update), | ||||||
|                             title: Text('${e.app.author}/${e.app.name}'), |                 label: const Text('Update All')), | ||||||
|                             subtitle: |         body: Center( | ||||||
|                                 Text(e.app.installedVersion ?? 'Not Installed'), |           child: appsProvider.loadingApps | ||||||
|                             trailing: e.downloadProgress != null |               ? const CircularProgressIndicator() | ||||||
|                                 ? Text( |               : appsProvider.apps.isEmpty | ||||||
|                                     'Downloading - ${e.downloadProgress!.toInt()}%') |                   ? Text( | ||||||
|                                 : (e.app.installedVersion != null && |                       'No Apps', | ||||||
|                                         e.app.installedVersion != |                       style: Theme.of(context).textTheme.headlineMedium, | ||||||
|                                             e.app.latestVersion |                     ) | ||||||
|                                     ? const Text('Update Available') |                   : RefreshIndicator( | ||||||
|                                     : null), |                       onRefresh: () { | ||||||
|                             onTap: () { |                         HapticFeedback.lightImpact(); | ||||||
|                               Navigator.push( |                         return appsProvider.checkUpdates(); | ||||||
|                                 context, |                       }, | ||||||
|                                 MaterialPageRoute( |                       child: ListView( | ||||||
|                                     builder: (context) => |                         children: sortedApps | ||||||
|                                         AppPage(appId: e.app.id)), |                             .map( | ||||||
|                               ); |                               (e) => ListTile( | ||||||
|                             }, |                                 title: Text('${e.app.author}/${e.app.name}'), | ||||||
|                           ), |                                 subtitle: Text( | ||||||
|                         ) |                                     e.app.installedVersion ?? 'Not Installed'), | ||||||
|                         .toList(), |                                 trailing: e.downloadProgress != null | ||||||
|                   ), |                                     ? Text( | ||||||
|                 ), |                                         'Downloading - ${e.downloadProgress?.toInt()}%') | ||||||
|     ); |                                     : (e.app.installedVersion != null && | ||||||
|  |                                             e.app.installedVersion != | ||||||
|  |                                                 e.app.latestVersion | ||||||
|  |                                         ? const Text('Update Available') | ||||||
|  |                                         : null), | ||||||
|  |                                 onTap: () { | ||||||
|  |                                   Navigator.push( | ||||||
|  |                                     context, | ||||||
|  |                                     MaterialPageRoute( | ||||||
|  |                                         builder: (context) => | ||||||
|  |                                             AppPage(appId: e.app.id)), | ||||||
|  |                                   ); | ||||||
|  |                                 }, | ||||||
|  |                               ), | ||||||
|  |                             ) | ||||||
|  |                             .toList(), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |         )); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/add_app.dart'; | import 'package:obtainium/pages/add_app.dart'; | ||||||
| import 'package:obtainium/pages/apps.dart'; | import 'package:obtainium/pages/apps.dart'; | ||||||
|  | import 'package:obtainium/pages/import_export.dart'; | ||||||
| import 'package:obtainium/pages/settings.dart'; | import 'package:obtainium/pages/settings.dart'; | ||||||
|  |  | ||||||
| class HomePage extends StatefulWidget { | class HomePage extends StatefulWidget { | ||||||
| @@ -11,31 +13,58 @@ class HomePage extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _HomePageState extends State<HomePage> { | class _HomePageState extends State<HomePage> { | ||||||
|   int selectedIndex = 1; |   List<int> selectedIndexHistory = []; | ||||||
|   List<Widget> pages = [ |   List<Widget> pages = [ | ||||||
|     const SettingsPage(), |  | ||||||
|     const AppsPage(), |     const AppsPage(), | ||||||
|     const AddAppPage() |     const AddAppPage(), | ||||||
|  |     const ImportExportPage(), | ||||||
|  |     const SettingsPage() | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Scaffold( |     return WillPopScope( | ||||||
|       appBar: AppBar(title: const Text('Obtainium')), |         child: Scaffold( | ||||||
|       body: pages.elementAt(selectedIndex), |           appBar: AppBar(title: const Text('Obtainium')), | ||||||
|       bottomNavigationBar: NavigationBar( |           body: pages.elementAt( | ||||||
|         destinations: const [ |               selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last), | ||||||
|           NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), |           bottomNavigationBar: NavigationBar( | ||||||
|           NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), |             destinations: const [ | ||||||
|           NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), |               NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), | ||||||
|         ], |               NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), | ||||||
|         onDestinationSelected: (int index) { |               NavigationDestination( | ||||||
|           setState(() { |                   icon: Icon(Icons.import_export), label: 'Import/Export'), | ||||||
|             selectedIndex = index; |               NavigationDestination( | ||||||
|           }); |                   icon: Icon(Icons.settings), label: 'Settings'), | ||||||
|         }, |             ], | ||||||
|         selectedIndex: selectedIndex, |             onDestinationSelected: (int index) { | ||||||
|       ), |               HapticFeedback.lightImpact(); | ||||||
|     ); |               setState(() { | ||||||
|  |                 if (index == 0) { | ||||||
|  |                   selectedIndexHistory.clear(); | ||||||
|  |                 } else if (selectedIndexHistory.isEmpty || | ||||||
|  |                     (selectedIndexHistory.isNotEmpty && | ||||||
|  |                         selectedIndexHistory.last != index)) { | ||||||
|  |                   int existingInd = selectedIndexHistory.indexOf(index); | ||||||
|  |                   if (existingInd >= 0) { | ||||||
|  |                     selectedIndexHistory.removeAt(existingInd); | ||||||
|  |                   } | ||||||
|  |                   selectedIndexHistory.add(index); | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             selectedIndex: | ||||||
|  |                 selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         onWillPop: () async { | ||||||
|  |           if (selectedIndexHistory.isNotEmpty) { | ||||||
|  |             setState(() { | ||||||
|  |               selectedIndexHistory.removeLast(); | ||||||
|  |             }); | ||||||
|  |             return false; | ||||||
|  |           } | ||||||
|  |           return true; | ||||||
|  |         }); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										288
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,288 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/components/generated_form_modal.dart'; | ||||||
|  | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:file_picker/file_picker.dart'; | ||||||
|  |  | ||||||
|  | class ImportExportPage extends StatefulWidget { | ||||||
|  |   const ImportExportPage({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<ImportExportPage> createState() => _ImportExportPageState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ImportExportPageState extends State<ImportExportPage> { | ||||||
|  |   bool importInProgress = false; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     SourceProvider sourceProvider = SourceProvider(); | ||||||
|  |     var settingsProvider = context.read<SettingsProvider>(); | ||||||
|  |     var appsProvider = context.read<AppsProvider>(); | ||||||
|  |  | ||||||
|  |     Future<List<List<String>>> addApps(List<String> urls) async { | ||||||
|  |       await settingsProvider.getInstallPermission(); | ||||||
|  |       List<dynamic> results = await sourceProvider.getApps(urls); | ||||||
|  |       List<App> apps = results[0]; | ||||||
|  |       Map<String, dynamic> errorsMap = results[1]; | ||||||
|  |       for (var app in apps) { | ||||||
|  |         if (appsProvider.apps.containsKey(app.id)) { | ||||||
|  |           errorsMap.addAll({app.id: 'App already added'}); | ||||||
|  |         } else { | ||||||
|  |           await appsProvider.saveApp(app); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       List<List<String>> errors = | ||||||
|  |           errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); | ||||||
|  |       return errors; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return Padding( | ||||||
|  |         padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), | ||||||
|  |         child: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             ElevatedButton( | ||||||
|  |                 onPressed: appsProvider.apps.isEmpty || importInProgress | ||||||
|  |                     ? null | ||||||
|  |                     : () { | ||||||
|  |                         HapticFeedback.lightImpact(); | ||||||
|  |                         appsProvider.exportApps().then((String path) { | ||||||
|  |                           ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |                             SnackBar(content: Text('Exported to $path')), | ||||||
|  |                           ); | ||||||
|  |                         }); | ||||||
|  |                       }, | ||||||
|  |                 child: const Text('Obtainium Export')), | ||||||
|  |             const SizedBox( | ||||||
|  |               height: 8, | ||||||
|  |             ), | ||||||
|  |             ElevatedButton( | ||||||
|  |                 onPressed: importInProgress | ||||||
|  |                     ? null | ||||||
|  |                     : () { | ||||||
|  |                         HapticFeedback.lightImpact(); | ||||||
|  |                         FilePicker.platform.pickFiles().then((result) { | ||||||
|  |                           setState(() { | ||||||
|  |                             importInProgress = true; | ||||||
|  |                           }); | ||||||
|  |                           if (result != null) { | ||||||
|  |                             String data = File(result.files.single.path!) | ||||||
|  |                                 .readAsStringSync(); | ||||||
|  |                             try { | ||||||
|  |                               jsonDecode(data); | ||||||
|  |                             } catch (e) { | ||||||
|  |                               throw 'Invalid input'; | ||||||
|  |                             } | ||||||
|  |                             appsProvider.importApps(data).then((value) { | ||||||
|  |                               ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |                                 SnackBar( | ||||||
|  |                                     content: Text( | ||||||
|  |                                         '$value App${value == 1 ? '' : 's'} Imported')), | ||||||
|  |                               ); | ||||||
|  |                             }); | ||||||
|  |                           } else { | ||||||
|  |                             // User canceled the picker | ||||||
|  |                           } | ||||||
|  |                         }).catchError((e) { | ||||||
|  |                           ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |                             SnackBar(content: Text(e.toString())), | ||||||
|  |                           ); | ||||||
|  |                         }).whenComplete(() { | ||||||
|  |                           setState(() { | ||||||
|  |                             importInProgress = false; | ||||||
|  |                           }); | ||||||
|  |                         }); | ||||||
|  |                       }, | ||||||
|  |                 child: const Text('Obtainium Import')), | ||||||
|  |             if (importInProgress) | ||||||
|  |               Column( | ||||||
|  |                 children: const [ | ||||||
|  |                   SizedBox( | ||||||
|  |                     height: 14, | ||||||
|  |                   ), | ||||||
|  |                   LinearProgressIndicator(), | ||||||
|  |                   SizedBox( | ||||||
|  |                     height: 14, | ||||||
|  |                   ), | ||||||
|  |                 ], | ||||||
|  |               ) | ||||||
|  |             else | ||||||
|  |               const Divider( | ||||||
|  |                 height: 32, | ||||||
|  |               ), | ||||||
|  |             TextButton( | ||||||
|  |                 onPressed: importInProgress | ||||||
|  |                     ? null | ||||||
|  |                     : () { | ||||||
|  |                         showDialog( | ||||||
|  |                             context: context, | ||||||
|  |                             builder: (BuildContext ctx) { | ||||||
|  |                               return GeneratedFormModal( | ||||||
|  |                                 title: 'Import from URL List', | ||||||
|  |                                 items: [ | ||||||
|  |                                   GeneratedFormItem('App URL List', true, 7) | ||||||
|  |                                 ], | ||||||
|  |                               ); | ||||||
|  |                             }).then((values) { | ||||||
|  |                           if (values != null) { | ||||||
|  |                             var urls = (values[0] as String).split('\n'); | ||||||
|  |                             setState(() { | ||||||
|  |                               importInProgress = true; | ||||||
|  |                             }); | ||||||
|  |                             addApps(urls).then((errors) { | ||||||
|  |                               if (errors.isEmpty) { | ||||||
|  |                                 ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |                                   SnackBar( | ||||||
|  |                                       content: | ||||||
|  |                                           Text('Imported ${urls.length} Apps')), | ||||||
|  |                                 ); | ||||||
|  |                               } else { | ||||||
|  |                                 showDialog( | ||||||
|  |                                     context: context, | ||||||
|  |                                     builder: (BuildContext ctx) { | ||||||
|  |                                       return ImportErrorDialog( | ||||||
|  |                                           urlsLength: urls.length, | ||||||
|  |                                           errors: errors); | ||||||
|  |                                     }); | ||||||
|  |                               } | ||||||
|  |                             }).catchError((e) { | ||||||
|  |                               ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |                                 SnackBar(content: Text(e.toString())), | ||||||
|  |                               ); | ||||||
|  |                             }).whenComplete(() { | ||||||
|  |                               setState(() { | ||||||
|  |                                 importInProgress = false; | ||||||
|  |                               }); | ||||||
|  |                             }); | ||||||
|  |                           } | ||||||
|  |                         }); | ||||||
|  |                       }, | ||||||
|  |                 child: const Text('Import from URL List')), | ||||||
|  |             ...sourceProvider.massSources | ||||||
|  |                 .map((source) => Column( | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                         children: [ | ||||||
|  |                           const SizedBox(height: 8), | ||||||
|  |                           TextButton( | ||||||
|  |                               onPressed: importInProgress | ||||||
|  |                                   ? null | ||||||
|  |                                   : () { | ||||||
|  |                                       showDialog( | ||||||
|  |                                           context: context, | ||||||
|  |                                           builder: (BuildContext ctx) { | ||||||
|  |                                             return GeneratedFormModal( | ||||||
|  |                                                 title: 'Import ${source.name}', | ||||||
|  |                                                 items: source.requiredArgs | ||||||
|  |                                                     .map((e) => | ||||||
|  |                                                         GeneratedFormItem( | ||||||
|  |                                                             e, true, 1)) | ||||||
|  |                                                     .toList()); | ||||||
|  |                                           }).then((values) { | ||||||
|  |                                         if (values != null) { | ||||||
|  |                                           source.getUrls(values).then((urls) { | ||||||
|  |                                             setState(() { | ||||||
|  |                                               importInProgress = true; | ||||||
|  |                                             }); | ||||||
|  |                                             addApps(urls).then((errors) { | ||||||
|  |                                               if (errors.isEmpty) { | ||||||
|  |                                                 ScaffoldMessenger.of(context) | ||||||
|  |                                                     .showSnackBar( | ||||||
|  |                                                   SnackBar( | ||||||
|  |                                                       content: Text( | ||||||
|  |                                                           'Imported ${urls.length} Apps')), | ||||||
|  |                                                 ); | ||||||
|  |                                               } else { | ||||||
|  |                                                 showDialog( | ||||||
|  |                                                     context: context, | ||||||
|  |                                                     builder: | ||||||
|  |                                                         (BuildContext ctx) { | ||||||
|  |                                                       return ImportErrorDialog( | ||||||
|  |                                                           urlsLength: | ||||||
|  |                                                               urls.length, | ||||||
|  |                                                           errors: errors); | ||||||
|  |                                                     }); | ||||||
|  |                                               } | ||||||
|  |                                             }).whenComplete(() { | ||||||
|  |                                               setState(() { | ||||||
|  |                                                 importInProgress = false; | ||||||
|  |                                               }); | ||||||
|  |                                             }); | ||||||
|  |                                           }).catchError((e) { | ||||||
|  |                                             ScaffoldMessenger.of(context) | ||||||
|  |                                                 .showSnackBar( | ||||||
|  |                                               SnackBar( | ||||||
|  |                                                   content: Text(e.toString())), | ||||||
|  |                                             ); | ||||||
|  |                                           }); | ||||||
|  |                                         } | ||||||
|  |                                       }); | ||||||
|  |                                     }, | ||||||
|  |                               child: Text('Import ${source.name}')) | ||||||
|  |                         ])) | ||||||
|  |                 .toList() | ||||||
|  |           ], | ||||||
|  |         )); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class ImportErrorDialog extends StatefulWidget { | ||||||
|  |   const ImportErrorDialog( | ||||||
|  |       {super.key, required this.urlsLength, required this.errors}); | ||||||
|  |  | ||||||
|  |   final int urlsLength; | ||||||
|  |   final List<List<String>> errors; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<ImportErrorDialog> createState() => _ImportErrorDialogState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _ImportErrorDialogState extends State<ImportErrorDialog> { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: const Text('Import Errors'), | ||||||
|  |       content: | ||||||
|  |           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||||
|  |         Text( | ||||||
|  |           '${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.', | ||||||
|  |           style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |         ), | ||||||
|  |         const SizedBox(height: 16), | ||||||
|  |         Text( | ||||||
|  |           'The following URLs had errors:', | ||||||
|  |           style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |         ), | ||||||
|  |         ...widget.errors.map((e) { | ||||||
|  |           return Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               children: [ | ||||||
|  |                 const SizedBox( | ||||||
|  |                   height: 16, | ||||||
|  |                 ), | ||||||
|  |                 Text(e[0]), | ||||||
|  |                 Text( | ||||||
|  |                   e[1], | ||||||
|  |                   style: const TextStyle(fontStyle: FontStyle.italic), | ||||||
|  |                 ) | ||||||
|  |               ]); | ||||||
|  |         }).toList() | ||||||
|  |       ]), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.lightImpact(); | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: const Text('Okay')) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:obtainium/services/settings_provider.dart'; | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| @@ -66,17 +67,132 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                           settingsProvider.colour = value; |                           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'), | ||||||
|  |                         ), | ||||||
|  |                         DropdownMenuItem( | ||||||
|  |                           value: 0, | ||||||
|  |                           child: Text('Never - Manual Only'), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                       onChanged: (value) { | ||||||
|  |                         if (value != null) { | ||||||
|  |                           settingsProvider.updateInterval = value; | ||||||
|  |                         } | ||||||
|  |                       }), | ||||||
|  |                   const SizedBox( | ||||||
|  |                     height: 16, | ||||||
|  |                   ), | ||||||
|  |                   DropdownButtonFormField( | ||||||
|  |                       decoration: | ||||||
|  |                           const InputDecoration(labelText: 'App Sort By'), | ||||||
|  |                       value: settingsProvider.sortColumn, | ||||||
|  |                       items: const [ | ||||||
|  |                         DropdownMenuItem( | ||||||
|  |                           value: SortColumnSettings.authorName, | ||||||
|  |                           child: Text('Author/Name'), | ||||||
|  |                         ), | ||||||
|  |                         DropdownMenuItem( | ||||||
|  |                           value: SortColumnSettings.nameAuthor, | ||||||
|  |                           child: Text('Name/Author'), | ||||||
|  |                         ), | ||||||
|  |                         DropdownMenuItem( | ||||||
|  |                           value: SortColumnSettings.added, | ||||||
|  |                           child: Text('As Added'), | ||||||
|  |                         ) | ||||||
|  |                       ], | ||||||
|  |                       onChanged: (value) { | ||||||
|  |                         if (value != null) { | ||||||
|  |                           settingsProvider.sortColumn = value; | ||||||
|  |                         } | ||||||
|  |                       }), | ||||||
|  |                   const SizedBox( | ||||||
|  |                     height: 16, | ||||||
|  |                   ), | ||||||
|  |                   DropdownButtonFormField( | ||||||
|  |                       decoration: | ||||||
|  |                           const InputDecoration(labelText: 'App Sort Order'), | ||||||
|  |                       value: settingsProvider.sortOrder, | ||||||
|  |                       items: const [ | ||||||
|  |                         DropdownMenuItem( | ||||||
|  |                           value: SortOrderSettings.ascending, | ||||||
|  |                           child: Text('Ascending'), | ||||||
|  |                         ), | ||||||
|  |                         DropdownMenuItem( | ||||||
|  |                           value: SortOrderSettings.descending, | ||||||
|  |                           child: Text('Descending'), | ||||||
|  |                         ), | ||||||
|  |                       ], | ||||||
|  |                       onChanged: (value) { | ||||||
|  |                         if (value != null) { | ||||||
|  |                           settingsProvider.sortOrder = value; | ||||||
|  |                         } | ||||||
|  |                       }), | ||||||
|  |                   const SizedBox( | ||||||
|  |                     height: 16, | ||||||
|  |                   ), | ||||||
|  |                   Row( | ||||||
|  |                     mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|  |                     children: [ | ||||||
|  |                       const Text('Show Source Webpage in App View'), | ||||||
|  |                       Switch( | ||||||
|  |                           value: settingsProvider.showAppWebpage, | ||||||
|  |                           onChanged: (value) { | ||||||
|  |                             settingsProvider.showAppWebpage = value; | ||||||
|  |                           }) | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|                   const Spacer(), |                   const Spacer(), | ||||||
|                   Row( |                   Row( | ||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |                     mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                     children: [ |                     children: [ | ||||||
|                       ElevatedButton.icon( |                       TextButton.icon( | ||||||
|  |                         style: ButtonStyle( | ||||||
|  |                           foregroundColor: | ||||||
|  |                               MaterialStateProperty.resolveWith<Color>( | ||||||
|  |                                   (Set<MaterialState> states) { | ||||||
|  |                             return Colors.grey; | ||||||
|  |                           }), | ||||||
|  |                         ), | ||||||
|                         onPressed: () { |                         onPressed: () { | ||||||
|  |                           HapticFeedback.lightImpact(); | ||||||
|                           launchUrlString(settingsProvider.sourceUrl, |                           launchUrlString(settingsProvider.sourceUrl, | ||||||
|                               mode: LaunchMode.externalApplication); |                               mode: LaunchMode.externalApplication); | ||||||
|                         }, |                         }, | ||||||
|                         icon: const Icon(Icons.code), |                         icon: const Icon(Icons.code), | ||||||
|                         label: const Text('Source'), |                         label: Text( | ||||||
|  |                           'Source', | ||||||
|  |                           style: Theme.of(context).textTheme.bodySmall, | ||||||
|  |                         ), | ||||||
|                       ) |                       ) | ||||||
|                     ], |                     ], | ||||||
|                   ), |                   ), | ||||||
|   | |||||||
							
								
								
									
										392
									
								
								lib/providers/apps_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,392 @@ | |||||||
|  | // 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 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
|  | import 'package:obtainium/providers/notifications_provider.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:path_provider/path_provider.dart'; | ||||||
|  | import 'package:flutter_fgbg/flutter_fgbg.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  | import 'package:http/http.dart'; | ||||||
|  | import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||||
|  |  | ||||||
|  | class AppInMemory { | ||||||
|  |   late App app; | ||||||
|  |   double? downloadProgress; | ||||||
|  |  | ||||||
|  |   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; | ||||||
|  |  | ||||||
|  |   // Variables to keep track of the app foreground status (installs can't run in the background) | ||||||
|  |   bool isForeground = true; | ||||||
|  |   late Stream<FGBGType> foregroundStream; | ||||||
|  |   late StreamSubscription<FGBGType> foregroundSubscription; | ||||||
|  |  | ||||||
|  |   AppsProvider( | ||||||
|  |       {bool shouldLoadApps = false, | ||||||
|  |       bool shouldCheckUpdatesAfterLoad = false, | ||||||
|  |       bool shouldDeleteAPKs = false}) { | ||||||
|  |     // Subscribe to changes in the app foreground status | ||||||
|  |     foregroundStream = FGBGEvents.stream.asBroadcastStream(); | ||||||
|  |     foregroundSubscription = foregroundStream.listen((event) async { | ||||||
|  |       isForeground = event == FGBGType.foreground; | ||||||
|  |       if (isForeground) await loadApps(); | ||||||
|  |     }); | ||||||
|  |     if (shouldDeleteAPKs) { | ||||||
|  |       deleteSavedAPKs(); | ||||||
|  |     } | ||||||
|  |     if (shouldLoadApps) { | ||||||
|  |       loadApps().then((_) { | ||||||
|  |         if (shouldCheckUpdatesAfterLoad) { | ||||||
|  |           checkUpdates(); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<ApkFile> downloadApp(String apkUrl, String appId) async { | ||||||
|  |     StreamedResponse response = | ||||||
|  |         await Client().send(Request('GET', Uri.parse(apkUrl))); | ||||||
|  |     File downloadFile = | ||||||
|  |         File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); | ||||||
|  |     if (downloadFile.existsSync()) { | ||||||
|  |       downloadFile.deleteSync(); | ||||||
|  |     } | ||||||
|  |     var length = response.contentLength; | ||||||
|  |     var received = 0; | ||||||
|  |     var sink = downloadFile.openWrite(); | ||||||
|  |  | ||||||
|  |     await response.stream.map((s) { | ||||||
|  |       received += s.length; | ||||||
|  |       apps[appId]!.downloadProgress = | ||||||
|  |           (length != null ? received / length * 100 : 30); | ||||||
|  |       notifyListeners(); | ||||||
|  |       return s; | ||||||
|  |     }).pipe(sink); | ||||||
|  |  | ||||||
|  |     await sink.close(); | ||||||
|  |     apps[appId]!.downloadProgress = null; | ||||||
|  |     notifyListeners(); | ||||||
|  |  | ||||||
|  |     if (response.statusCode != 200) { | ||||||
|  |       downloadFile.deleteSync(); | ||||||
|  |       throw response.reasonPhrase ?? 'Unknown Error'; | ||||||
|  |     } | ||||||
|  |     return ApkFile(appId, downloadFile); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool areDownloadsRunning() => apps.values | ||||||
|  |       .where((element) => element.downloadProgress != null) | ||||||
|  |       .isNotEmpty; | ||||||
|  |  | ||||||
|  |   // Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it | ||||||
|  |   // Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed | ||||||
|  |   // Returns upon successful download, regardless of installation result | ||||||
|  |   Future<bool> 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'; | ||||||
|  |       } | ||||||
|  |       // If the App has more than one APK, the user should pick one | ||||||
|  |       String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; | ||||||
|  |       if (apps[id]!.app.apkUrls.length > 1) { | ||||||
|  |         apkUrl = await showDialog( | ||||||
|  |             context: context, | ||||||
|  |             builder: (BuildContext ctx) { | ||||||
|  |               return APKPicker(app: apps[id]!.app, initVal: apkUrl); | ||||||
|  |             }); | ||||||
|  |       } | ||||||
|  |       // If the picked APK comes from an origin different from the source, get user confirmation | ||||||
|  |       if (apkUrl != null && | ||||||
|  |           Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) { | ||||||
|  |         if (await showDialog( | ||||||
|  |                 context: context, | ||||||
|  |                 builder: (BuildContext ctx) { | ||||||
|  |                   return APKOriginWarningDialog( | ||||||
|  |                       sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!); | ||||||
|  |                 }) != | ||||||
|  |             true) { | ||||||
|  |           apkUrl = null; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       if (apkUrl != null) { | ||||||
|  |         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||||
|  |         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||||
|  |           apps[id]!.app.preferredApkIndex = urlInd; | ||||||
|  |           await saveApp(apps[id]!.app); | ||||||
|  |         } | ||||||
|  |         appsToInstall.putIfAbsent(id, () => apkUrl!); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries | ||||||
|  |         .map((entry) => downloadApp(entry.value, entry.key))); | ||||||
|  |  | ||||||
|  |     if (!isForeground) { | ||||||
|  |       await notificationsProvider.notify(completeInstallationNotification, | ||||||
|  |           cancelExisting: true); | ||||||
|  |       await FGBGEvents.stream.first == FGBGType.foreground; | ||||||
|  |       await notificationsProvider.cancel(completeInstallationNotification.id); | ||||||
|  |       // We need to wait for the App to come to the foreground to install it | ||||||
|  |       // Can't try to call install plugin in a background isolate (may not have worked anyways) because of: | ||||||
|  |       // https://github.com/flutter/flutter/issues/13937 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 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 | ||||||
|  |     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); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return downloadedFiles.isNotEmpty; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<Directory> getAppsDir() async { | ||||||
|  |     Directory appsDir = Directory( | ||||||
|  |         '${(await getExternalStorageDirectory())?.path as String}/app_data'); | ||||||
|  |     if (!appsDir.existsSync()) { | ||||||
|  |       appsDir.createSync(); | ||||||
|  |     } | ||||||
|  |     return appsDir; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> deleteSavedAPKs() async { | ||||||
|  |     (await getExternalStorageDirectory()) | ||||||
|  |         ?.listSync() | ||||||
|  |         .where((element) => element.path.endsWith('.apk')) | ||||||
|  |         .forEach((element) { | ||||||
|  |       element.deleteSync(); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> loadApps() async { | ||||||
|  |     loadingApps = true; | ||||||
|  |     notifyListeners(); | ||||||
|  |     List<FileSystemEntity> appFiles = (await getAppsDir()) | ||||||
|  |         .listSync() | ||||||
|  |         .where((item) => item.path.toLowerCase().endsWith('.json')) | ||||||
|  |         .toList(); | ||||||
|  |     apps.clear(); | ||||||
|  |     for (int i = 0; i < appFiles.length; i++) { | ||||||
|  |       App app = | ||||||
|  |           App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); | ||||||
|  |       apps.putIfAbsent(app.id, () => AppInMemory(app, null)); | ||||||
|  |     } | ||||||
|  |     loadingApps = false; | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> saveApp(App app) async { | ||||||
|  |     File('${(await getAppsDir()).path}/${app.id}.json') | ||||||
|  |         .writeAsStringSync(jsonEncode(app.toJson())); | ||||||
|  |     apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), | ||||||
|  |         ifAbsent: () => AppInMemory(app, null)); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> removeApp(String appId) async { | ||||||
|  |     File file = File('${(await getAppsDir()).path}/$appId.json'); | ||||||
|  |     if (file.existsSync()) { | ||||||
|  |       file.deleteSync(); | ||||||
|  |     } | ||||||
|  |     if (apps.containsKey(appId)) { | ||||||
|  |       apps.remove(appId); | ||||||
|  |     } | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool checkAppObjectForUpdate(App app) { | ||||||
|  |     if (!apps.containsKey(app.id)) { | ||||||
|  |       throw 'App not found'; | ||||||
|  |     } | ||||||
|  |     return app.latestVersion != apps[app.id]?.app.installedVersion; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<App?> getUpdate(String appId) async { | ||||||
|  |     App? currentApp = apps[appId]!.app; | ||||||
|  |     App newApp = await SourceProvider().getApp(currentApp.url); | ||||||
|  |     if (newApp.latestVersion != currentApp.latestVersion) { | ||||||
|  |       newApp.installedVersion = currentApp.installedVersion; | ||||||
|  |       if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||||
|  |         newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||||
|  |       } | ||||||
|  |       await saveApp(newApp); | ||||||
|  |       return newApp; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List<App>> checkUpdates() async { | ||||||
|  |     List<App> updates = []; | ||||||
|  |     if (!gettingUpdates) { | ||||||
|  |       gettingUpdates = true; | ||||||
|  |  | ||||||
|  |       List<String> appIds = apps.keys.toList(); | ||||||
|  |       for (int i = 0; i < appIds.length; i++) { | ||||||
|  |         App? newApp = await getUpdate(appIds[i]); | ||||||
|  |         if (newApp != null) { | ||||||
|  |           updates.add(newApp); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       gettingUpdates = false; | ||||||
|  |     } | ||||||
|  |     return updates; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   List<String> getExistingUpdates() { | ||||||
|  |     List<String> updateAppIds = []; | ||||||
|  |     List<String> appIds = apps.keys.toList(); | ||||||
|  |     for (int i = 0; i < appIds.length; i++) { | ||||||
|  |       App? app = apps[appIds[i]]!.app; | ||||||
|  |       if (app.installedVersion != app.latestVersion) { | ||||||
|  |         updateAppIds.add(app.id); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     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() { | ||||||
|  |     foregroundSubscription.cancel(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class APKPicker extends StatefulWidget { | ||||||
|  |   const APKPicker({super.key, required this.app, this.initVal}); | ||||||
|  |  | ||||||
|  |   final App app; | ||||||
|  |   final String? initVal; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<APKPicker> createState() => _APKPickerState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _APKPickerState extends State<APKPicker> { | ||||||
|  |   String? apkUrl; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     apkUrl ??= widget.initVal; | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: const Text('Pick an APK'), | ||||||
|  |       content: Column(children: [ | ||||||
|  |         Text('${widget.app.name} has more than one package:'), | ||||||
|  |         const SizedBox(height: 16), | ||||||
|  |         ...widget.app.apkUrls.map((u) => RadioListTile<String>( | ||||||
|  |             title: Text(Uri.parse(u).pathSegments.last), | ||||||
|  |             value: u, | ||||||
|  |             groupValue: apkUrl, | ||||||
|  |             onChanged: (String? val) { | ||||||
|  |               setState(() { | ||||||
|  |                 apkUrl = val; | ||||||
|  |               }); | ||||||
|  |             })) | ||||||
|  |       ]), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.lightImpact(); | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: const Text('Cancel')), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.heavyImpact(); | ||||||
|  |               Navigator.of(context).pop(apkUrl); | ||||||
|  |             }, | ||||||
|  |             child: const Text('Continue')) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class APKOriginWarningDialog extends StatefulWidget { | ||||||
|  |   const APKOriginWarningDialog( | ||||||
|  |       {super.key, required this.sourceUrl, required this.apkUrl}); | ||||||
|  |  | ||||||
|  |   final String sourceUrl; | ||||||
|  |   final String apkUrl; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> { | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return AlertDialog( | ||||||
|  |       scrollable: true, | ||||||
|  |       title: const Text('Warning'), | ||||||
|  |       content: Text( | ||||||
|  |           'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'), | ||||||
|  |       actions: [ | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.lightImpact(); | ||||||
|  |               Navigator.of(context).pop(null); | ||||||
|  |             }, | ||||||
|  |             child: const Text('Cancel')), | ||||||
|  |         TextButton( | ||||||
|  |             onPressed: () { | ||||||
|  |               HapticFeedback.heavyImpact(); | ||||||
|  |               Navigator.of(context).pop(true); | ||||||
|  |             }, | ||||||
|  |             child: const Text('Continue')) | ||||||
|  |       ], | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										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); | ||||||
|  | } | ||||||
							
								
								
									
										105
									
								
								lib/providers/settings_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,105 @@ | |||||||
|  | // 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 } | ||||||
|  |  | ||||||
|  | enum ColourSettings { basic, materialYou } | ||||||
|  |  | ||||||
|  | enum SortColumnSettings { added, nameAuthor, authorName } | ||||||
|  |  | ||||||
|  | enum SortOrderSettings { ascending, descending } | ||||||
|  |  | ||||||
|  | class SettingsProvider with ChangeNotifier { | ||||||
|  |   SharedPreferences? prefs; | ||||||
|  |  | ||||||
|  |   String sourceUrl = 'https://github.com/ImranR98/Obtainium'; | ||||||
|  |  | ||||||
|  |   // Not done in constructor as we want to be able to await it | ||||||
|  |   Future<void> initializeSettings() async { | ||||||
|  |     prefs = await SharedPreferences.getInstance(); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ThemeSettings get theme { | ||||||
|  |     return ThemeSettings | ||||||
|  |         .values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set theme(ThemeSettings t) { | ||||||
|  |     prefs?.setInt('theme', t.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ColourSettings get colour { | ||||||
|  |     return ColourSettings | ||||||
|  |         .values[prefs?.getInt('colour') ?? ColourSettings.basic.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set colour(ColourSettings t) { | ||||||
|  |     prefs?.setInt('colour', t.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   int get updateInterval { | ||||||
|  |     return prefs?.getInt('updateInterval') ?? 1440; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set updateInterval(int min) { | ||||||
|  |     prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   SortColumnSettings get sortColumn { | ||||||
|  |     return SortColumnSettings | ||||||
|  |         .values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set sortColumn(SortColumnSettings s) { | ||||||
|  |     prefs?.setInt('sortColumn', s.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   SortOrderSettings get sortOrder { | ||||||
|  |     return SortOrderSettings.values[ | ||||||
|  |         prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set sortOrder(SortOrderSettings s) { | ||||||
|  |     prefs?.setInt('sortOrder', s.index); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   bool get showAppWebpage { | ||||||
|  |     return prefs?.getBool('showAppWebpage') ?? true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set showAppWebpage(bool show) { | ||||||
|  |     prefs?.setBool('showAppWebpage', show); | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										497
									
								
								lib/providers/source_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,497 @@ | |||||||
|  | // 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 = []; | ||||||
|  |   late int preferredApkIndex; | ||||||
|  |   App(this.id, this.url, this.author, this.name, this.installedVersion, | ||||||
|  |       this.latestVersion, this.apkUrls, this.preferredApkIndex); | ||||||
|  |  | ||||||
|  |   @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'])), | ||||||
|  |         json['preferredApkIndex'] == null | ||||||
|  |             ? 0 | ||||||
|  |             : json['preferredApkIndex'] as int, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   Map<String, dynamic> toJson() => { | ||||||
|  |         'id': id, | ||||||
|  |         'url': url, | ||||||
|  |         'author': author, | ||||||
|  |         'name': name, | ||||||
|  |         'installedVersion': installedVersion, | ||||||
|  |         'latestVersion': latestVersion, | ||||||
|  |         'apkUrls': jsonEncode(apkUrls), | ||||||
|  |         'preferredApkIndex': preferredApkIndex | ||||||
|  |       }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | escapeRegEx(String s) { | ||||||
|  |   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||||
|  |     return "\\${x[0]}"; | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const String couldNotFindReleases = 'Unable to fetch release info'; | ||||||
|  | const String couldNotFindLatestVersion = | ||||||
|  |     'Could not determine latest release version'; | ||||||
|  | const String notValidURL = 'Not a valid URL'; | ||||||
|  | const String noAPKFound = 'No APK found'; | ||||||
|  |  | ||||||
|  | List<String> getLinksFromParsedHTML( | ||||||
|  |         Document dom, RegExp hrefPattern, String prependToLinks) => | ||||||
|  |     dom | ||||||
|  |         .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 host; | ||||||
|  |   String standardizeURL(String url); | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl); | ||||||
|  |   AppNames getAppNames(String standardUrl); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class GitHub implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'github.com'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw notValidURL; | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { | ||||||
|  |     Response res = await get(Uri.parse( | ||||||
|  |         'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var releases = jsonDecode(res.body) as List<dynamic>; | ||||||
|  |       // Right now, the latest non-prerelease version is picked | ||||||
|  |       // If none exists, the latest prerelease version is picked | ||||||
|  |       // In the future, the user could be given a choice | ||||||
|  |       var nonPrereleaseReleases = | ||||||
|  |           releases.where((element) => element['prerelease'] != true).toList(); | ||||||
|  |       var latestRelease = nonPrereleaseReleases.isNotEmpty | ||||||
|  |           ? nonPrereleaseReleases[0] | ||||||
|  |           : releases.isNotEmpty | ||||||
|  |               ? releases[0] | ||||||
|  |               : null; | ||||||
|  |       if (latestRelease == null) { | ||||||
|  |         throw couldNotFindReleases; | ||||||
|  |       } | ||||||
|  |       List<dynamic>? assets = latestRelease['assets']; | ||||||
|  |       List<String>? apkUrlList = assets | ||||||
|  |           ?.map((e) { | ||||||
|  |             return e['browser_download_url'] != null | ||||||
|  |                 ? e['browser_download_url'] as String | ||||||
|  |                 : ''; | ||||||
|  |           }) | ||||||
|  |           .where((element) => element.toLowerCase().endsWith('.apk')) | ||||||
|  |           .toList(); | ||||||
|  |       if (apkUrlList == null || apkUrlList.isEmpty) { | ||||||
|  |         throw noAPKFound; | ||||||
|  |       } | ||||||
|  |       String? version = latestRelease['tag_name']; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, apkUrlList); | ||||||
|  |     } else { | ||||||
|  |       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||||
|  |         throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     String temp = standardUrl.substring(standardUrl.indexOf('://') + 3); | ||||||
|  |     List<String> names = temp.substring(temp.indexOf('/') + 1).split('/'); | ||||||
|  |     return AppNames(names[0], names[1]); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class GitLab implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'gitlab.com'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw notValidURL; | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var standardUri = Uri.parse(standardUrl); | ||||||
|  |       var parsedHtml = parse(res.body); | ||||||
|  |       var entry = parsedHtml.querySelector('entry'); | ||||||
|  |       var entryContent = | ||||||
|  |           parse(parseFragment(entry?.querySelector('content')!.innerHtml).text); | ||||||
|  |       var apkUrlList = [ | ||||||
|  |         ...getLinksFromParsedHTML( | ||||||
|  |             entryContent, | ||||||
|  |             RegExp( | ||||||
|  |                 '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', | ||||||
|  |                 caseSensitive: false), | ||||||
|  |             standardUri.origin), | ||||||
|  |         // GitLab releases may contain links to externally hosted APKs | ||||||
|  |         ...getLinksFromParsedHTML(entryContent, | ||||||
|  |                 RegExp('/[^/]+\\.apk\$', caseSensitive: false), '') | ||||||
|  |             .where((element) => Uri.parse(element).host != '') | ||||||
|  |             .toList() | ||||||
|  |       ]; | ||||||
|  |       if (apkUrlList.isEmpty) { | ||||||
|  |         throw noAPKFound; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       var entryId = entry?.querySelector('id')?.innerHtml; | ||||||
|  |       var version = | ||||||
|  |           entryId == null ? null : Uri.parse(entryId).pathSegments.last; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, apkUrlList); | ||||||
|  |     } else { | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     // Same as GitHub | ||||||
|  |     return GitHub().getAppNames(standardUrl); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Signal implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'signal.org'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     return 'https://$host'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { | ||||||
|  |     Response res = | ||||||
|  |         await get(Uri.parse('https://updates.$host/android/latest.json')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var json = jsonDecode(res.body); | ||||||
|  |       String? apkUrl = json['url']; | ||||||
|  |       if (apkUrl == null) { | ||||||
|  |         throw noAPKFound; | ||||||
|  |       } | ||||||
|  |       String? version = json['versionName']; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, [apkUrl]); | ||||||
|  |     } else { | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class FDroid implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'f-droid.org'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw notValidURL; | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { | ||||||
|  |     Response res = await get(Uri.parse(standardUrl)); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var latestReleaseDiv = | ||||||
|  |           parse(res.body).querySelector('#latest.package-version'); | ||||||
|  |       var apkUrl = latestReleaseDiv | ||||||
|  |           ?.querySelector('.package-version-download a') | ||||||
|  |           ?.attributes['href']; | ||||||
|  |       if (apkUrl == null) { | ||||||
|  |         throw noAPKFound; | ||||||
|  |       } | ||||||
|  |       var version = latestReleaseDiv | ||||||
|  |           ?.querySelector('.package-version-header b') | ||||||
|  |           ?.innerHtml | ||||||
|  |           .split(' ') | ||||||
|  |           .last; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, [apkUrl]); | ||||||
|  |     } else { | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Mullvad implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'mullvad.net'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw notValidURL; | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { | ||||||
|  |     Response res = await get(Uri.parse('$standardUrl/en/download/android')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var version = parse(res.body) | ||||||
|  |           .querySelector('p.subtitle.is-6') | ||||||
|  |           ?.querySelector('a') | ||||||
|  |           ?.attributes['href'] | ||||||
|  |           ?.split('/') | ||||||
|  |           .last; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       return APKDetails( | ||||||
|  |           version, ['https://mullvad.net/download/app/apk/latest']); | ||||||
|  |     } else { | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames('Mullvad-VPN', 'Mullvad-VPN'); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class IzzyOnDroid implements AppSource { | ||||||
|  |   @override | ||||||
|  |   late String host = 'android.izzysoft.de'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String standardizeURL(String url) { | ||||||
|  |     RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); | ||||||
|  |     RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); | ||||||
|  |     if (match == null) { | ||||||
|  |       throw notValidURL; | ||||||
|  |     } | ||||||
|  |     return url.substring(0, match.end); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<APKDetails> getLatestAPKDetails(String standardUrl) async { | ||||||
|  |     Response res = await get(Uri.parse(standardUrl)); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       var parsedHtml = parse(res.body); | ||||||
|  |       var multipleVersionApkUrls = parsedHtml | ||||||
|  |           .querySelectorAll('a') | ||||||
|  |           .where((element) => | ||||||
|  |               element.attributes['href']?.toLowerCase().endsWith('.apk') ?? | ||||||
|  |               false) | ||||||
|  |           .map((e) => 'https://$host${e.attributes['href'] ?? ''}') | ||||||
|  |           .toList(); | ||||||
|  |       if (multipleVersionApkUrls.isEmpty) { | ||||||
|  |         throw noAPKFound; | ||||||
|  |       } | ||||||
|  |       var version = parsedHtml | ||||||
|  |           .querySelector('#keydata') | ||||||
|  |           ?.querySelectorAll('b') | ||||||
|  |           .where( | ||||||
|  |               (element) => element.innerHtml.toLowerCase().contains('version')) | ||||||
|  |           .toList()[0] | ||||||
|  |           .parentNode | ||||||
|  |           ?.parentNode | ||||||
|  |           ?.children[1] | ||||||
|  |           .innerHtml; | ||||||
|  |       if (version == null) { | ||||||
|  |         throw couldNotFindLatestVersion; | ||||||
|  |       } | ||||||
|  |       return APKDetails(version, [multipleVersionApkUrls[0]]); | ||||||
|  |     } else { | ||||||
|  |       throw couldNotFindReleases; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AppNames getAppNames(String standardUrl) { | ||||||
|  |     return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SourceProvider { | ||||||
|  |   List<AppSource> sources = [ | ||||||
|  |     GitHub(), | ||||||
|  |     GitLab(), | ||||||
|  |     FDroid(), | ||||||
|  |     Mullvad(), | ||||||
|  |     Signal(), | ||||||
|  |     IzzyOnDroid() | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   List<MassAppSource> massSources = [GitHubStars()]; | ||||||
|  |  | ||||||
|  |   // Add more source classes here so they are available via the service | ||||||
|  |   AppSource getSource(String url) { | ||||||
|  |     AppSource? source; | ||||||
|  |     for (var s in sources) { | ||||||
|  |       if (url.toLowerCase().contains('://${s.host}')) { | ||||||
|  |         source = s; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (source == null) { | ||||||
|  |       throw 'URL does not match a known source'; | ||||||
|  |     } | ||||||
|  |     return source; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<App> getApp(String url) async { | ||||||
|  |     if (url.toLowerCase().indexOf('http://') != 0 && | ||||||
|  |         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.host}', | ||||||
|  |         standardUrl, | ||||||
|  |         names.author[0].toUpperCase() + names.author.substring(1), | ||||||
|  |         names.name[0].toUpperCase() + names.name.substring(1), | ||||||
|  |         null, | ||||||
|  |         apk.version, | ||||||
|  |         apk.apkUrls, | ||||||
|  |         apk.apkUrls.length - 1); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Returns a length 2 list, where the first element is a list of Apps and | ||||||
|  |   /// the second is a Map<String, dynamic> of URLs and errors | ||||||
|  |   Future<List<dynamic>> getApps(List<String> urls) async { | ||||||
|  |     List<App> apps = []; | ||||||
|  |     Map<String, dynamic> errors = {}; | ||||||
|  |     for (var url in urls) { | ||||||
|  |       try { | ||||||
|  |         apps.add(await getApp(url)); | ||||||
|  |       } catch (e) { | ||||||
|  |         errors.addAll(<String, dynamic>{url: e}); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return [apps, errors]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   List<String> getSourceHosts() => sources.map((e) => e.host).toList(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abstract class MassAppSource { | ||||||
|  |   late String name; | ||||||
|  |   late List<String> requiredArgs; | ||||||
|  |   Future<List<String>> getUrls(List<String> args); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class GitHubStars implements MassAppSource { | ||||||
|  |   @override | ||||||
|  |   late String name = 'GitHub Starred Repos'; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   late List<String> requiredArgs = ['Username']; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<List<String>> getUrls(List<String> args) async { | ||||||
|  |     if (args.length != requiredArgs.length) { | ||||||
|  |       throw 'Wrong number of arguments provided'; | ||||||
|  |     } | ||||||
|  |     Response res = | ||||||
|  |         await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       return (jsonDecode(res.body) as List<dynamic>) | ||||||
|  |           .map((e) => e['html_url'] as String) | ||||||
|  |           .toList(); | ||||||
|  |     } else { | ||||||
|  |       if (res.headers['x-ratelimit-remaining'] == '0') { | ||||||
|  |         throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       throw 'Unable to find user\'s starred repos'; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,222 +0,0 @@ | |||||||
| // Provider that manages App-related state and provides functions to retrieve App info download/install Apps |  | ||||||
|  |  | ||||||
| import 'dart:async'; |  | ||||||
| import 'dart:convert'; |  | ||||||
| import 'dart:io'; |  | ||||||
| import 'dart:ui'; |  | ||||||
|  |  | ||||||
| import 'package:flutter/material.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:http/http.dart'; |  | ||||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; |  | ||||||
|  |  | ||||||
| class AppInMemory { |  | ||||||
|   late App app; |  | ||||||
|   double? downloadProgress; |  | ||||||
|  |  | ||||||
|   AppInMemory(this.app, this.downloadProgress); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| 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; |  | ||||||
|  |  | ||||||
|   AppsProvider({bool bg = false}) { |  | ||||||
|     initializeNotifs(); |  | ||||||
|     // Subscribe to changes in the app foreground status |  | ||||||
|     foregroundSubscription = FGBGEvents.stream.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'; |  | ||||||
|     } |  | ||||||
|     StreamedResponse response = |  | ||||||
|         await Client().send(Request('GET', Uri.parse(apps[appId]!.app.apkUrl))); |  | ||||||
|     File downloadFile = |  | ||||||
|         File('${(await getExternalStorageDirectory())!.path}/$appId.apk'); |  | ||||||
|     if (downloadFile.existsSync()) { |  | ||||||
|       downloadFile.deleteSync(); |  | ||||||
|     } |  | ||||||
|     var length = response.contentLength; |  | ||||||
|     var received = 0; |  | ||||||
|     var sink = downloadFile.openWrite(); |  | ||||||
|  |  | ||||||
|     await response.stream.map((s) { |  | ||||||
|       received += s.length; |  | ||||||
|       apps[appId]!.downloadProgress = |  | ||||||
|           (length != null ? received / length * 100 : 30); |  | ||||||
|       notifyListeners(); |  | ||||||
|       return s; |  | ||||||
|     }).pipe(sink); |  | ||||||
|  |  | ||||||
|     await sink.close(); |  | ||||||
|     apps[appId]!.downloadProgress = null; |  | ||||||
|     notifyListeners(); |  | ||||||
|  |  | ||||||
|     if (response.statusCode != 200) { |  | ||||||
|       downloadFile.deleteSync(); |  | ||||||
|       throw response.reasonPhrase ?? 'Unknown Error'; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     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 |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // 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); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<Directory> getAppsDir() async { |  | ||||||
|     Directory appsDir = Directory( |  | ||||||
|         '${(await getExternalStorageDirectory())?.path as String}/app_data'); |  | ||||||
|     if (!appsDir.existsSync()) { |  | ||||||
|       appsDir.createSync(); |  | ||||||
|     } |  | ||||||
|     return appsDir; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> loadApps() async { |  | ||||||
|     loadingApps = true; |  | ||||||
|     notifyListeners(); |  | ||||||
|     List<FileSystemEntity> appFiles = (await getAppsDir()) |  | ||||||
|         .listSync() |  | ||||||
|         .where((item) => item.path.toLowerCase().endsWith('.json')) |  | ||||||
|         .toList(); |  | ||||||
|     apps.clear(); |  | ||||||
|     for (int i = 0; i < appFiles.length; i++) { |  | ||||||
|       App app = |  | ||||||
|           App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); |  | ||||||
|       apps.putIfAbsent(app.id, () => AppInMemory(app, null)); |  | ||||||
|     } |  | ||||||
|     loadingApps = false; |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> saveApp(App app) async { |  | ||||||
|     File('${(await getAppsDir()).path}/${app.id}.json') |  | ||||||
|         .writeAsStringSync(jsonEncode(app.toJson())); |  | ||||||
|     apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), |  | ||||||
|         ifAbsent: () => AppInMemory(app, null)); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> removeApp(String appId) async { |  | ||||||
|     File file = File('${(await getAppsDir()).path}/$appId.json'); |  | ||||||
|     if (file.existsSync()) { |  | ||||||
|       file.deleteSync(); |  | ||||||
|     } |  | ||||||
|     if (apps.containsKey(appId)) { |  | ||||||
|       apps.remove(appId); |  | ||||||
|     } |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   bool checkAppObjectForUpdate(App app) { |  | ||||||
|     if (!apps.containsKey(app.id)) { |  | ||||||
|       throw 'App not found'; |  | ||||||
|     } |  | ||||||
|     return app.latestVersion != apps[app.id]?.app.installedVersion; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<App?> getUpdate(String appId) async { |  | ||||||
|     App? currentApp = apps[appId]!.app; |  | ||||||
|     App newApp = await SourceService().getApp(currentApp.url); |  | ||||||
|     if (newApp.latestVersion != currentApp.latestVersion) { |  | ||||||
|       newApp.installedVersion = currentApp.installedVersion; |  | ||||||
|       await saveApp(newApp); |  | ||||||
|       return newApp; |  | ||||||
|     } |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<List<App>> getUpdates() async { |  | ||||||
|     List<App> updates = []; |  | ||||||
|     if (!gettingUpdates) { |  | ||||||
|       gettingUpdates = true; |  | ||||||
|  |  | ||||||
|       List<String> appIds = apps.keys.toList(); |  | ||||||
|       for (int i = 0; i < appIds.length; i++) { |  | ||||||
|         App? newApp = await getUpdate(appIds[i]); |  | ||||||
|         if (newApp != null) { |  | ||||||
|           updates.add(newApp); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       gettingUpdates = false; |  | ||||||
|     } |  | ||||||
|     return updates; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> installUpdates() async { |  | ||||||
|     List<String> appIds = apps.keys.toList(); |  | ||||||
|     for (int i = 0; i < appIds.length; i++) { |  | ||||||
|       App? app = apps[appIds[i]]!.app; |  | ||||||
|       if (app.installedVersion != app.latestVersion) { |  | ||||||
|         await downloadAndInstallLatestApp(app.id); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     IsolateNameServer.removePortNameMapping('downloader_send_port'); |  | ||||||
|     foregroundSubscription?.cancel(); |  | ||||||
|     super.dispose(); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,47 +0,0 @@ | |||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'package:shared_preferences/shared_preferences.dart'; |  | ||||||
|  |  | ||||||
| enum ThemeSettings { system, light, dark } |  | ||||||
|  |  | ||||||
| enum ColourSettings { basic, materialYou } |  | ||||||
|  |  | ||||||
| class SettingsProvider with ChangeNotifier { |  | ||||||
|   SharedPreferences? prefs; |  | ||||||
|  |  | ||||||
|   String sourceUrl = 'https://github.com/ImranR98/Obtainium'; |  | ||||||
|  |  | ||||||
|   // Not done in constructor as we want to be able to await it |  | ||||||
|   Future<void> initializeSettings() async { |  | ||||||
|     prefs = await SharedPreferences.getInstance(); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   ThemeSettings get theme { |  | ||||||
|     return ThemeSettings |  | ||||||
|         .values[prefs?.getInt('theme') ?? ThemeSettings.system.index]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   set theme(ThemeSettings t) { |  | ||||||
|     print(t); |  | ||||||
|     prefs?.setInt('theme', t.index); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   ColourSettings get colour { |  | ||||||
|     return ColourSettings |  | ||||||
|         .values[prefs?.getInt('colour') ?? ColourSettings.basic.index]; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   set colour(ColourSettings t) { |  | ||||||
|     prefs?.setInt('colour', t.index); |  | ||||||
|     notifyListeners(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   checkAndFlipFirstRun() { |  | ||||||
|     bool result = prefs?.getBool('firstRun') ?? true; |  | ||||||
|     if (result) { |  | ||||||
|       prefs?.setBool('firstRun', false); |  | ||||||
|     } |  | ||||||
|     return result; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,159 +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 { |  | ||||||
|   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 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'; |  | ||||||
|     } |  | ||||||
|     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}', |  | ||||||
|         standardUrl, |  | ||||||
|         names.author[0].toUpperCase() + names.author.substring(1), |  | ||||||
|         names.name[0].toUpperCase() + names.name.substring(1), |  | ||||||
|         null, |  | ||||||
|         apk.version, |  | ||||||
|         apk.downloadUrl); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										134
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -91,14 +91,56 @@ packages: | |||||||
|       name: dbus |       name: dbus | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.7.7" |     version: "0.7.8" | ||||||
|  |   device_info_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "4.1.2" | ||||||
|  |   device_info_plus_linux: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_linux | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_macos: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_macos | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_platform_interface | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_web: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_web | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_windows: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_windows | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "4.1.0" | ||||||
|   dynamic_color: |   dynamic_color: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: dynamic_color |       name: dynamic_color | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.5.3" |     version: "1.5.4" | ||||||
|   fake_async: |   fake_async: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -119,7 +161,14 @@ packages: | |||||||
|       name: file |       name: file | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.2" |     version: "6.1.4" | ||||||
|  |   file_picker: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: file_picker | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "5.1.0" | ||||||
|   flutter: |   flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -152,7 +201,7 @@ packages: | |||||||
|       name: flutter_local_notifications |       name: flutter_local_notifications | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "9.7.0" |     version: "9.9.1" | ||||||
|   flutter_local_notifications_linux: |   flutter_local_notifications_linux: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -167,6 +216,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "5.0.0" |     version: "5.0.0" | ||||||
|  |   flutter_plugin_android_lifecycle: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: flutter_plugin_android_lifecycle | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.0.7" | ||||||
|   flutter_test: |   flutter_test: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -177,6 +233,13 @@ packages: | |||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     version: "0.0.0" | ||||||
|  |   fluttertoast: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: fluttertoast | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "8.0.9" | ||||||
|   html: |   html: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -281,7 +344,7 @@ packages: | |||||||
|       name: path_provider_android |       name: path_provider_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.17" |     version: "2.0.20" | ||||||
|   path_provider_ios: |   path_provider_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -316,7 +379,42 @@ packages: | |||||||
|       name: path_provider_windows |       name: path_provider_windows | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|  |   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: |   petitparser: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -337,7 +435,7 @@ packages: | |||||||
|       name: plugin_platform_interface |       name: plugin_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|   process: |   process: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -365,7 +463,7 @@ packages: | |||||||
|       name: shared_preferences_android |       name: shared_preferences_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.12" |     version: "2.0.13" | ||||||
|   shared_preferences_ios: |   shared_preferences_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -393,7 +491,7 @@ packages: | |||||||
|       name: shared_preferences_platform_interface |       name: shared_preferences_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.0" |     version: "2.1.0" | ||||||
|   shared_preferences_web: |   shared_preferences_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -454,7 +552,7 @@ packages: | |||||||
|       name: test_api |       name: test_api | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.4.12" |     version: "0.4.14" | ||||||
|   timezone: |   timezone: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -482,7 +580,7 @@ packages: | |||||||
|       name: url_launcher_android |       name: url_launcher_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.17" |     version: "6.0.19" | ||||||
|   url_launcher_ios: |   url_launcher_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -531,7 +629,7 @@ packages: | |||||||
|       name: vector_math |       name: vector_math | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|   webview_flutter: |   webview_flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -545,28 +643,28 @@ packages: | |||||||
|       name: webview_flutter_android |       name: webview_flutter_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.5" |     version: "2.10.1" | ||||||
|   webview_flutter_platform_interface: |   webview_flutter_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_platform_interface |       name: webview_flutter_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.1" |     version: "1.9.3" | ||||||
|   webview_flutter_wkwebview: |   webview_flutter_wkwebview: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_wkwebview |       name: webview_flutter_wkwebview | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.3" |     version: "2.9.4" | ||||||
|   win32: |   win32: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: win32 |       name: win32 | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.7.0" |     version: "3.0.0" | ||||||
|   workmanager: |   workmanager: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -580,7 +678,7 @@ packages: | |||||||
|       name: xdg_directories |       name: xdg_directories | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.2.0+1" |     version: "0.2.0+2" | ||||||
|   xml: |   xml: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -597,4 +695,4 @@ packages: | |||||||
|     version: "3.1.1" |     version: "3.1.1" | ||||||
| sdks: | sdks: | ||||||
|   dart: ">=2.19.0-79.0.dev <3.0.0" |   dart: ">=2.19.0-79.0.dev <3.0.0" | ||||||
|   flutter: ">=3.1.0-0.0.pre.1036" |   flutter: ">=3.3.0" | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								pubspec.yaml
									
									
									
									
									
								
							
							
						
						| @@ -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 | # 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 | # 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. | # of the product and file versions while build-number is used as the build suffix. | ||||||
| version: 0.1.0+1 | version: 0.2.1+12 # When changing this, update the tag in main() accordingly | ||||||
|  |  | ||||||
| environment: | environment: | ||||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' |   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||||
| @@ -35,19 +35,23 @@ dependencies: | |||||||
|  |  | ||||||
|   # The following adds the Cupertino Icons font to your application. |   # The following adds the Cupertino Icons font to your application. | ||||||
|   # Use with the CupertinoIcons class for iOS style icons. |   # Use with the CupertinoIcons class for iOS style icons. | ||||||
|   cupertino_icons: ^1.0.2 |   cupertino_icons: ^1.0.5 | ||||||
|   path_provider: ^2.0.11 |   path_provider: ^2.0.11 | ||||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this |   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||||
|   flutter_local_notifications: ^9.7.0 |   flutter_local_notifications: ^9.9.1 | ||||||
|   provider: ^6.0.3 |   provider: ^6.0.3 | ||||||
|   http: ^0.13.5 |   http: ^0.13.5 | ||||||
|   webview_flutter: ^3.0.4 |   webview_flutter: ^3.0.4 | ||||||
|   workmanager: ^0.5.0 |   workmanager: ^0.5.0 | ||||||
|   dynamic_color: ^1.5.3 |   dynamic_color: ^1.5.4 | ||||||
|   install_plugin_v2: ^1.0.0 # Try replacing this |   install_plugin_v2: ^1.0.0 # Try replacing this | ||||||
|   html: ^0.15.0 |   html: ^0.15.0 | ||||||
|   shared_preferences: ^2.0.15 |   shared_preferences: ^2.0.15 | ||||||
|   url_launcher: ^6.1.5 |   url_launcher: ^6.1.5 | ||||||
|  |   permission_handler: ^10.0.0 | ||||||
|  |   fluttertoast: ^8.0.9 | ||||||
|  |   device_info_plus: ^4.1.2 | ||||||
|  |   file_picker: ^5.1.0 | ||||||
|  |  | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
| @@ -60,13 +64,13 @@ dev_dependencies: | |||||||
|   # activated in the `analysis_options.yaml` file located at the root of your |   # activated in the `analysis_options.yaml` file located at the root of your | ||||||
|   # package. See that file for information about deactivating specific lint |   # package. See that file for information about deactivating specific lint | ||||||
|   # rules and activating additional ones. |   # rules and activating additional ones. | ||||||
|   flutter_lints: ^2.0.0 |   flutter_lints: ^2.0.1 | ||||||
|  |  | ||||||
| flutter_icons: | flutter_icons: | ||||||
|   android: true |   android: true | ||||||
|   image_path: "assets/icon.png" |   image_path: "assets/graphics/icon.png" | ||||||
|   adaptive_icon_background: "#FFFFFF" |   adaptive_icon_background: "#FFFFFF" | ||||||
|   adaptive_icon_foreground: "assets/icon.png" |   adaptive_icon_foreground: "assets/graphics/icon.png" | ||||||
|  |  | ||||||
| # For information on the generic Dart part of this file, see the | # For information on the generic Dart part of this file, see the | ||||||
| # following page: https://dart.dev/tools/pub/pubspec | # following page: https://dart.dev/tools/pub/pubspec | ||||||
|   | |||||||