Compare commits
	
		
			64 Commits
		
	
	
		
			v0.1.0-bet
			...
			v0.2.2-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2272f8b4e6 | ||
|  | 9514062a3a | ||
|  | da57018b90 | ||
|  | 87e31c37aa | ||
|  | cb4dfff1b9 | ||
|  | 911b06bfb6 | ||
|  | 53513bfdd1 | ||
|  | 681092d895 | ||
|  | 0f6b6253de | ||
|  | c724b276ab | ||
|  | 35369273bd | ||
|  | 0b1863a227 | ||
|  | 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 | 
							
								
								
									
										21
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,17 +1,26 @@ | ||||
| # Obtainium | ||||
| #  Obtainium | ||||
|  | ||||
| Get Android App Updates Directly From the Source. | ||||
|  | ||||
| Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | ||||
|  | ||||
| Currently supported App sources: | ||||
| - GitHub | ||||
|  | ||||
| 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/) | ||||
| - [IzzyOnDroid](https://android.izzysoft.de/) | ||||
| - [Mullvad](https://mullvad.net/en/) | ||||
| - [Signal](https://signal.org/) | ||||
|  | ||||
| ## Limitations | ||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | ||||
| - Already installed apps are not detected, for the above reason along with the fact that App sources do not provide App IDs (like `org.example.app`) to allow for comparisons. | ||||
| - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. | ||||
| - 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 { | ||||
|     compileSdkVersion flutter.compileSdkVersion | ||||
|     compileSdkVersion 33 | ||||
|     ndkVersion flutter.ndkVersion | ||||
|  | ||||
|     compileOptions { | ||||
| @@ -54,7 +54,7 @@ android { | ||||
|         // You can update the following values to match your application needs. | ||||
|         // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. | ||||
|         minSdkVersion 23 | ||||
|         targetSdkVersion 32 | ||||
|         targetSdkVersion 33 | ||||
|         versionCode flutterVersionCode.toInteger() | ||||
|         versionName flutterVersionName | ||||
|     } | ||||
|   | ||||
							
								
								
									
										
											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 | 
							
								
								
									
										29
									
								
								lib/components/custom_app_bar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,29 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class CustomAppBar extends StatefulWidget { | ||||
|   const CustomAppBar({super.key, required this.title}); | ||||
|  | ||||
|   final String title; | ||||
|  | ||||
|   @override | ||||
|   State<CustomAppBar> createState() => _CustomAppBarState(); | ||||
| } | ||||
|  | ||||
| class _CustomAppBarState extends State<CustomAppBar> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return SliverAppBar( | ||||
|       pinned: true, | ||||
|       automaticallyImplyLeading: false, | ||||
|       expandedHeight: 100, | ||||
|       flexibleSpace: FlexibleSpaceBar( | ||||
|         titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), | ||||
|         title: Text( | ||||
|           widget.title, | ||||
|           style: | ||||
|               TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										80
									
								
								lib/components/generated_form_modal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| 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: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               if (_formKey.currentState?.validate() == true) { | ||||
|                 HapticFeedback.selectionClick(); | ||||
|                 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 | ||||
							
								
								
									
										117
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						| @@ -1,54 +1,67 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/pages/home.dart'; | ||||
| import 'package:obtainium/services/apps_provider.dart'; | ||||
| import 'package:obtainium/services/settings_provider.dart'; | ||||
| import 'package:obtainium/services/source_service.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/notifications_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:workmanager/workmanager.dart'; | ||||
| import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
|  | ||||
| void backgroundUpdateCheck() { | ||||
|   Workmanager().executeTask((task, inputData) async { | ||||
|     var appsProvider = AppsProvider(bg: true); | ||||
| const String currentReleaseTag = | ||||
|     'v0.2.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| void bgTaskCallback() { | ||||
|   // Background update checking process | ||||
|   Workmanager().executeTask((task, taskName) async { | ||||
|     var notificationsProvider = NotificationsProvider(); | ||||
|     await notificationsProvider.notify(checkingUpdatesNotification); | ||||
|     try { | ||||
|       var appsProvider = AppsProvider(); | ||||
|       await notificationsProvider | ||||
|           .cancel(ErrorCheckingUpdatesNotification('').id); | ||||
|       await appsProvider.loadApps(); | ||||
|     List<App> updates = await appsProvider.getUpdates(); | ||||
|       List<App> updates = await appsProvider.checkUpdates(); | ||||
|       if (updates.isNotEmpty) { | ||||
|       String message = updates.length == 1 | ||||
|           ? '${updates[0].name} has an update.' | ||||
|           : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; | ||||
|       await appsProvider.downloaderNotifications.cancel(2); | ||||
|       await appsProvider.notify( | ||||
|           2, | ||||
|           'Updates Available', | ||||
|           message, | ||||
|           'UPDATES_AVAILABLE', | ||||
|           'Updates Available', | ||||
|           'Notifies the user that updates are available for one or more Apps tracked by Obtainium'); | ||||
|         notificationsProvider.notify(UpdateNotification(updates), | ||||
|             cancelExisting: true); | ||||
|       } | ||||
|       return Future.value(true); | ||||
|     } catch (e) { | ||||
|       notificationsProvider.notify( | ||||
|           ErrorCheckingUpdatesNotification(e.toString()), | ||||
|           cancelExisting: true); | ||||
|       return Future.value(false); | ||||
|     } finally { | ||||
|       await notificationsProvider.cancel(checkingUpdatesNotification.id); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| void main() async { | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { | ||||
|     SystemChrome.setSystemUIOverlayStyle( | ||||
|       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), | ||||
|     ); | ||||
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||
|   } | ||||
|   Workmanager().initialize( | ||||
|     backgroundUpdateCheck, | ||||
|     bgTaskCallback, | ||||
|   ); | ||||
|   await Workmanager().cancelByUniqueName('update-apps-task'); | ||||
|   await Workmanager().registerPeriodicTask( | ||||
|       'update-apps-task', 'backgroundUpdateCheck', | ||||
|       frequency: const Duration(minutes: 15), | ||||
|       initialDelay: const Duration(minutes: 15), | ||||
|       constraints: Constraints(networkType: NetworkType.connected)); | ||||
|   runApp(MultiProvider( | ||||
|     providers: [ | ||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), | ||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()) | ||||
|       ChangeNotifierProvider( | ||||
|           create: (context) => AppsProvider( | ||||
|               shouldLoadApps: true, | ||||
|               shouldCheckUpdatesAfterLoad: true, | ||||
|               shouldDeleteAPKs: true)), | ||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||
|       Provider(create: (context) => NotificationsProvider()) | ||||
|     ], | ||||
|     child: const MyApp(), | ||||
|   )); | ||||
| @@ -61,31 +74,41 @@ class MyApp extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return DynamicColorBuilder( | ||||
|         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { | ||||
|       // Initialize the settings provider (if needed) and perform first-run actions if needed | ||||
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||
|     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||
|  | ||||
|     if (settingsProvider.prefs == null) { | ||||
|         settingsProvider.initializeSettings().then((_) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } 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) { | ||||
|             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); | ||||
|             }); | ||||
|         // 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( | ||||
|         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { | ||||
|       // Decide on a colour/brightness scheme based on OS and user settings | ||||
|       ColorScheme lightColorScheme; | ||||
|       ColorScheme darkColorScheme; | ||||
|       if (lightDynamic != null && | ||||
| @@ -98,7 +121,6 @@ class MyApp extends StatelessWidget { | ||||
|         darkColorScheme = ColorScheme.fromSeed( | ||||
|             seedColor: defaultThemeColour, brightness: Brightness.dark); | ||||
|       } | ||||
|  | ||||
|       return MaterialApp( | ||||
|           title: 'Obtainium', | ||||
|           theme: ThemeData( | ||||
| @@ -111,7 +133,8 @@ class MyApp extends StatelessWidget { | ||||
|               useMaterial3: true, | ||||
|               colorScheme: settingsProvider.theme == ThemeSettings.light | ||||
|                   ? lightColorScheme | ||||
|                   : darkColorScheme), | ||||
|                   : darkColorScheme, | ||||
|               fontFamily: 'Metropolis'), | ||||
|           home: const HomePage()); | ||||
|     }); | ||||
|   } | ||||
|   | ||||
| @@ -1,8 +1,12 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/services/apps_provider.dart'; | ||||
| import 'package:obtainium/services/source_service.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:obtainium/providers/source_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| class AddAppPage extends StatefulWidget { | ||||
|   const AddAppPage({super.key}); | ||||
| @@ -18,16 +22,25 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Center( | ||||
|     SourceProvider sourceProvider = SourceProvider(); | ||||
|     return CustomScrollView(slivers: <Widget>[ | ||||
|       const CustomAppBar(title: 'Add App'), | ||||
|       SliverFillRemaining( | ||||
|           hasScrollBody: false, | ||||
|           child: Center( | ||||
|             child: Form( | ||||
|                 key: _formKey, | ||||
|                 child: Column( | ||||
|                   mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                   children: [ | ||||
|           const Spacer(), | ||||
|                     Container(), | ||||
|                     Padding( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|               child: TextFormField( | ||||
|                       padding: const EdgeInsets.all(16), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                         children: [ | ||||
|                           TextFormField( | ||||
|                             decoration: const InputDecoration( | ||||
|                                 hintText: 'https://github.com/Author/Project', | ||||
|                                 helperText: 'Enter the App source URL'), | ||||
| @@ -40,36 +53,48 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               } | ||||
|                               return null; | ||||
|                             }, | ||||
|               )), | ||||
|                           ), | ||||
|                           Padding( | ||||
|             padding: | ||||
|                 const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), | ||||
|                             padding: const EdgeInsets.symmetric(vertical: 16.0), | ||||
|                             child: ElevatedButton( | ||||
|                               onPressed: gettingAppInfo | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.selectionClick(); | ||||
|                                       if (_formKey.currentState!.validate()) { | ||||
|                                         setState(() { | ||||
|                                           gettingAppInfo = true; | ||||
|                                         }); | ||||
|                         SourceService() | ||||
|                             .getApp(urlInputController.value.text) | ||||
|                                         sourceProvider | ||||
|                                             .getApp( | ||||
|                                                 urlInputController.value.text) | ||||
|                                             .then((app) { | ||||
|                           var appsProvider = context.read<AppsProvider>(); | ||||
|                           if (appsProvider.apps.containsKey(app.id)) { | ||||
|                                           var appsProvider = | ||||
|                                               context.read<AppsProvider>(); | ||||
|                                           var settingsProvider = | ||||
|                                               context.read<SettingsProvider>(); | ||||
|                                           if (appsProvider.apps | ||||
|                                               .containsKey(app.id)) { | ||||
|                                             throw 'App already added'; | ||||
|                                           } | ||||
|                                           settingsProvider | ||||
|                                               .getInstallPermission() | ||||
|                                               .then((_) { | ||||
|                                             appsProvider.saveApp(app).then((_) { | ||||
|                                               urlInputController.clear(); | ||||
|                                               Navigator.push( | ||||
|                                                   context, | ||||
|                                                   MaterialPageRoute( | ||||
|                                                       builder: (context) => | ||||
|                                         AppPage(appId: app.id))); | ||||
|                                                           AppPage( | ||||
|                                                               appId: app.id))); | ||||
|                                             }); | ||||
|                                           }); | ||||
|                                         }).catchError((e) { | ||||
|                           ScaffoldMessenger.of(context).showSnackBar( | ||||
|                             SnackBar(content: Text(e.toString())), | ||||
|                                           ScaffoldMessenger.of(context) | ||||
|                                               .showSnackBar( | ||||
|                                             SnackBar( | ||||
|                                                 content: Text(e.toString())), | ||||
|                                           ); | ||||
|                                         }).whenComplete(() { | ||||
|                                           setState(() { | ||||
| @@ -81,10 +106,42 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               child: const Text('Add'), | ||||
|                             ), | ||||
|                           ), | ||||
|           const Spacer(), | ||||
|           if (gettingAppInfo) const LinearProgressIndicator(), | ||||
|                         ], | ||||
|                       ), | ||||
|     )); | ||||
|                     ), | ||||
|                     Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                         children: [ | ||||
|                           const Text( | ||||
|                             'Supported Sources:', | ||||
|                             // style: TextStyle(fontWeight: FontWeight.bold), | ||||
|                             // style: Theme.of(context).textTheme.bodySmall, | ||||
|                           ), | ||||
|                           const SizedBox( | ||||
|                             height: 8, | ||||
|                           ), | ||||
|                           ...sourceProvider | ||||
|                               .getSourceHosts() | ||||
|                               .map((e) => GestureDetector( | ||||
|                                   onTap: () { | ||||
|                                     launchUrlString('https://$e', | ||||
|                                         mode: LaunchMode.externalApplication); | ||||
|                                   }, | ||||
|                                   child: Text( | ||||
|                                     e, | ||||
|                                     style: const TextStyle( | ||||
|                                         decoration: TextDecoration.underline, | ||||
|                                         fontStyle: FontStyle.italic), | ||||
|                                   ))) | ||||
|                               .toList() | ||||
|                         ]), | ||||
|                     if (gettingAppInfo) | ||||
|                       const LinearProgressIndicator() | ||||
|                     else | ||||
|                       Container(), | ||||
|                   ], | ||||
|                 )), | ||||
|           )) | ||||
|     ]); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/services/apps_provider.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.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:provider/provider.dart'; | ||||
|  | ||||
| @@ -16,17 +20,70 @@ class _AppPageState extends State<AppPage> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||
|     if (app?.app.installedVersion != null) { | ||||
|       appsProvider.getUpdate(app!.app.id); | ||||
|     } | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         title: Text('${app?.app.author}/${app?.app.name}'), | ||||
|       ), | ||||
|       body: WebView( | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|       body: CustomScrollView(slivers: <Widget>[ | ||||
|         CustomAppBar(title: '${app?.app.name}'), | ||||
|         SliverFillRemaining( | ||||
|           child: settingsProvider.showAppWebpage | ||||
|               ? 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( | ||||
|           padding: EdgeInsets.fromLTRB( | ||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||
| @@ -38,17 +95,93 @@ class _AppPageState extends State<AppPage> { | ||||
|                   child: Row( | ||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                       children: [ | ||||
|                         if (app?.app.installedVersion != app?.app.latestVersion) | ||||
|                           IconButton( | ||||
|                               onPressed: () { | ||||
|                                 showDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (BuildContext ctx) { | ||||
|                                       return AlertDialog( | ||||
|                                         title: Text( | ||||
|                                             'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'), | ||||
|                                         actions: [ | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text('No')), | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 HapticFeedback.selectionClick(); | ||||
|                                                 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)) | ||||
|                         else | ||||
|                           IconButton( | ||||
|                               onPressed: () { | ||||
|                                 showDialog( | ||||
|                                     context: context, | ||||
|                                     builder: (BuildContext ctx) { | ||||
|                                       return AlertDialog( | ||||
|                                         title: const Text('App Not Installed?'), | ||||
|                                         actions: [ | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text('No')), | ||||
|                                           TextButton( | ||||
|                                               onPressed: () { | ||||
|                                                 HapticFeedback.selectionClick(); | ||||
|                                                 var updatedApp = app?.app; | ||||
|                                                 if (updatedApp != null) { | ||||
|                                                   updatedApp.installedVersion = | ||||
|                                                       null; | ||||
|                                                   appsProvider | ||||
|                                                       .saveApp(updatedApp); | ||||
|                                                 } | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }, | ||||
|                                               child: const Text( | ||||
|                                                   'Yes, Mark as Not Installed')) | ||||
|                                         ], | ||||
|                                       ); | ||||
|                                     }); | ||||
|                               }, | ||||
|                               tooltip: 'Mark as Not Installed', | ||||
|                               icon: const Icon(Icons.no_cell_outlined)), | ||||
|                         const SizedBox(width: 16.0), | ||||
|                         Expanded( | ||||
|                             child: ElevatedButton( | ||||
|                                 onPressed: (app?.app.installedVersion == null || | ||||
|                                             appsProvider | ||||
|                                                 .checkAppObjectForUpdate( | ||||
|                                                     app!.app)) && | ||||
|                                         app?.downloadProgress == null | ||||
|                                         !appsProvider.areDownloadsRunning() | ||||
|                                     ? () { | ||||
|                                         HapticFeedback.heavyImpact(); | ||||
|                                         appsProvider | ||||
|                                             .downloadAndInstallLatestApp( | ||||
|                                                 app!.app.id); | ||||
|                                                 [app!.app.id], | ||||
|                                                 context).then((res) { | ||||
|                                           if (res && mounted) { | ||||
|                                             Navigator.of(context).pop(); | ||||
|                                           } | ||||
|                                         }); | ||||
|                                       } | ||||
|                                     : null, | ||||
|                                 child: Text(app?.app.installedVersion == null | ||||
| @@ -69,6 +202,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                                           actions: [ | ||||
|                                             TextButton( | ||||
|                                                 onPressed: () { | ||||
|                                                   HapticFeedback | ||||
|                                                       .selectionClick(); | ||||
|                                                   appsProvider | ||||
|                                                       .removeApp(app!.app.id) | ||||
|                                                       .then((_) { | ||||
| @@ -89,8 +224,10 @@ class _AppPageState extends State<AppPage> { | ||||
|                                       }); | ||||
|                                 }, | ||||
|                           style: TextButton.styleFrom( | ||||
|                               foregroundColor: Theme.of(context).errorColor, | ||||
|                               surfaceTintColor: Theme.of(context).errorColor), | ||||
|                               foregroundColor: | ||||
|                                   Theme.of(context).colorScheme.error, | ||||
|                               surfaceTintColor: | ||||
|                                   Theme.of(context).colorScheme.error), | ||||
|                           child: const Text('Remove'), | ||||
|                         ), | ||||
|                       ])), | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/services/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
| class AppsPage extends StatefulWidget { | ||||
| @@ -14,31 +17,71 @@ class _AppsPageState extends State<AppsPage> { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     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( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         floatingActionButton: existingUpdateAppIds.isEmpty | ||||
|             ? null | ||||
|             : ElevatedButton.icon( | ||||
|                 onPressed: appsProvider.areDownloadsRunning() | ||||
|                     ? null | ||||
|                     : () { | ||||
|                         HapticFeedback.heavyImpact(); | ||||
|                         settingsProvider.getInstallPermission().then((_) { | ||||
|                           appsProvider.downloadAndInstallLatestApp( | ||||
|                               existingUpdateAppIds, context); | ||||
|                         }); | ||||
|                       }, | ||||
|                 icon: const Icon(Icons.install_mobile_outlined), | ||||
|                 label: const Text('Install All')), | ||||
|         body: RefreshIndicator( | ||||
|             onRefresh: () { | ||||
|               HapticFeedback.lightImpact(); | ||||
|               return appsProvider.checkUpdates(); | ||||
|             }, | ||||
|             child: CustomScrollView(slivers: <Widget>[ | ||||
|               const CustomAppBar(title: 'Apps'), | ||||
|               if (appsProvider.loadingApps || appsProvider.apps.isEmpty) | ||||
|                 SliverFillRemaining( | ||||
|                     child: Center( | ||||
|                         child: appsProvider.loadingApps | ||||
|                             ? const CircularProgressIndicator() | ||||
|           : appsProvider.apps.isEmpty | ||||
|               ? Text( | ||||
|                             : Text( | ||||
|                                 'No Apps', | ||||
|                   style: Theme.of(context).textTheme.headline4, | ||||
|                 ) | ||||
|               : RefreshIndicator( | ||||
|                   onRefresh: appsProvider.getUpdates, | ||||
|                   child: ListView( | ||||
|                     children: appsProvider.apps.values | ||||
|                         .map( | ||||
|                           (e) => ListTile( | ||||
|                             title: Text('${e.app.author}/${e.app.name}'), | ||||
|                             subtitle: | ||||
|                                 Text(e.app.installedVersion ?? 'Not Installed'), | ||||
|                             trailing: e.downloadProgress != null | ||||
|                                 style: | ||||
|                                     Theme.of(context).textTheme.headlineMedium, | ||||
|                               ))), | ||||
|               SliverList( | ||||
|                   delegate: SliverChildBuilderDelegate( | ||||
|                       (BuildContext context, int index) { | ||||
|                 return ListTile( | ||||
|                   title: Text( | ||||
|                       '${sortedApps[index].app.author}/${sortedApps[index].app.name}'), | ||||
|                   subtitle: Text(sortedApps[index].app.installedVersion ?? | ||||
|                       'Not Installed'), | ||||
|                   trailing: sortedApps[index].downloadProgress != null | ||||
|                       ? Text( | ||||
|                                     'Downloading - ${e.downloadProgress!.toInt()}%') | ||||
|                                 : (e.app.installedVersion != null && | ||||
|                                         e.app.installedVersion != | ||||
|                                             e.app.latestVersion | ||||
|                           'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') | ||||
|                       : (sortedApps[index].app.installedVersion != null && | ||||
|                               sortedApps[index].app.installedVersion != | ||||
|                                   sortedApps[index].app.latestVersion | ||||
|                           ? const Text('Update Available') | ||||
|                           : null), | ||||
|                   onTap: () { | ||||
| @@ -46,14 +89,11 @@ class _AppsPageState extends State<AppsPage> { | ||||
|                       context, | ||||
|                       MaterialPageRoute( | ||||
|                           builder: (context) => | ||||
|                                         AppPage(appId: e.app.id)), | ||||
|                               AppPage(appId: sortedApps[index].app.id)), | ||||
|                     ); | ||||
|                   }, | ||||
|                           ), | ||||
|                         ) | ||||
|                         .toList(), | ||||
|                   ), | ||||
|                 ), | ||||
|                 ); | ||||
|               }, childCount: sortedApps.length)) | ||||
|             ]))); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| import 'package:animations/animations.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/pages/add_app.dart'; | ||||
| import 'package:obtainium/pages/apps.dart'; | ||||
| import 'package:obtainium/pages/import_export.dart'; | ||||
| import 'package:obtainium/pages/settings.dart'; | ||||
|  | ||||
| class HomePage extends StatefulWidget { | ||||
| @@ -10,32 +13,82 @@ class HomePage extends StatefulWidget { | ||||
|   State<HomePage> createState() => _HomePageState(); | ||||
| } | ||||
|  | ||||
| class NavigationPageItem { | ||||
|   late String title; | ||||
|   late IconData icon; | ||||
|   late Widget widget; | ||||
|  | ||||
|   NavigationPageItem(this.title, this.icon, this.widget); | ||||
| } | ||||
|  | ||||
| class _HomePageState extends State<HomePage> { | ||||
|   int selectedIndex = 1; | ||||
|   List<Widget> pages = [ | ||||
|     const SettingsPage(), | ||||
|     const AppsPage(), | ||||
|     const AddAppPage() | ||||
|   List<int> selectedIndexHistory = []; | ||||
|  | ||||
|   List<NavigationPageItem> pages = [ | ||||
|     NavigationPageItem('Apps', Icons.apps, const AppsPage()), | ||||
|     NavigationPageItem('Add App', Icons.add, const AddAppPage()), | ||||
|     NavigationPageItem( | ||||
|         'Import/Export', Icons.import_export, const ImportExportPage()), | ||||
|     NavigationPageItem('Settings', Icons.settings, const SettingsPage()) | ||||
|   ]; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|       appBar: AppBar(title: const Text('Obtainium')), | ||||
|       body: pages.elementAt(selectedIndex), | ||||
|     return WillPopScope( | ||||
|         child: Scaffold( | ||||
|           backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|           body: PageTransitionSwitcher( | ||||
|             transitionBuilder: ( | ||||
|               Widget child, | ||||
|               Animation<double> animation, | ||||
|               Animation<double> secondaryAnimation, | ||||
|             ) { | ||||
|               return SharedAxisTransition( | ||||
|                 animation: animation, | ||||
|                 secondaryAnimation: secondaryAnimation, | ||||
|                 transitionType: SharedAxisTransitionType.horizontal, | ||||
|                 child: child, | ||||
|               ); | ||||
|             }, | ||||
|             child: pages | ||||
|                 .elementAt(selectedIndexHistory.isEmpty | ||||
|                     ? 0 | ||||
|                     : selectedIndexHistory.last) | ||||
|                 .widget, | ||||
|           ), | ||||
|           bottomNavigationBar: NavigationBar( | ||||
|         destinations: const [ | ||||
|           NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), | ||||
|           NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), | ||||
|           NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), | ||||
|         ], | ||||
|             destinations: pages | ||||
|                 .map((e) => | ||||
|                     NavigationDestination(icon: Icon(e.icon), label: e.title)) | ||||
|                 .toList(), | ||||
|             onDestinationSelected: (int index) { | ||||
|               HapticFeedback.selectionClick(); | ||||
|               setState(() { | ||||
|             selectedIndex = index; | ||||
|                 if (index == 0) { | ||||
|                   selectedIndexHistory.clear(); | ||||
|                 } else if (selectedIndexHistory.isEmpty || | ||||
|                     (selectedIndexHistory.isNotEmpty && | ||||
|                         selectedIndexHistory.last != index)) { | ||||
|                   int existingInd = selectedIndexHistory.indexOf(index); | ||||
|                   if (existingInd >= 0) { | ||||
|                     selectedIndexHistory.removeAt(existingInd); | ||||
|                   } | ||||
|                   selectedIndexHistory.add(index); | ||||
|                 } | ||||
|               }); | ||||
|             }, | ||||
|         selectedIndex: selectedIndex, | ||||
|             selectedIndex: | ||||
|                 selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, | ||||
|           ), | ||||
|     ); | ||||
|         ), | ||||
|         onWillPop: () async { | ||||
|           if (selectedIndexHistory.isNotEmpty) { | ||||
|             setState(() { | ||||
|               selectedIndexHistory.removeLast(); | ||||
|             }); | ||||
|             return false; | ||||
|           } | ||||
|           return true; | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										333
									
								
								lib/pages/import_export.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,333 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.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>(); | ||||
|     var outlineButtonStyle = ButtonStyle( | ||||
|       shape: MaterialStateProperty.all( | ||||
|         StadiumBorder( | ||||
|           side: BorderSide( | ||||
|             width: 1, | ||||
|             color: Theme.of(context).colorScheme.primary, | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     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 CustomScrollView(slivers: <Widget>[ | ||||
|       const CustomAppBar(title: 'Import/Export'), | ||||
|       SliverFillRemaining( | ||||
|           hasScrollBody: false, | ||||
|           child: Padding( | ||||
|               padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|                 children: [ | ||||
|                   Row( | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                           child: TextButton( | ||||
|                               style: outlineButtonStyle, | ||||
|                               onPressed: appsProvider.apps.isEmpty || | ||||
|                                       importInProgress | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.selectionClick(); | ||||
|                                       appsProvider | ||||
|                                           .exportApps() | ||||
|                                           .then((String path) { | ||||
|                                         ScaffoldMessenger.of(context) | ||||
|                                             .showSnackBar( | ||||
|                                           SnackBar( | ||||
|                                               content: | ||||
|                                                   Text('Exported to $path')), | ||||
|                                         ); | ||||
|                                       }); | ||||
|                                     }, | ||||
|                               child: const Text('Obtainium Export'))), | ||||
|                       const SizedBox( | ||||
|                         width: 16, | ||||
|                       ), | ||||
|                       Expanded( | ||||
|                           child: TextButton( | ||||
|                               style: outlineButtonStyle, | ||||
|                               onPressed: importInProgress | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.selectionClick(); | ||||
|                                       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: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Okay')) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,5 +1,7 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/services/settings_provider.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:obtainium/components/custom_app_bar.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| @@ -17,14 +19,25 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|     if (settingsProvider.prefs == null) { | ||||
|       settingsProvider.initializeSettings(); | ||||
|     } | ||||
|     return Padding( | ||||
|     return CustomScrollView(slivers: <Widget>[ | ||||
|       const CustomAppBar(title: 'Add App'), | ||||
|       SliverFillRemaining( | ||||
|           hasScrollBody: true, | ||||
|           child: Padding( | ||||
|               padding: const EdgeInsets.all(16), | ||||
|               child: settingsProvider.prefs == null | ||||
|                   ? Container() | ||||
|                   : Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Text( | ||||
|                           'Appearance', | ||||
|                           style: TextStyle( | ||||
|                               color: Theme.of(context).colorScheme.primary), | ||||
|                         ), | ||||
|                         DropdownButtonFormField( | ||||
|                       decoration: const InputDecoration(labelText: 'Theme'), | ||||
|                             decoration: | ||||
|                                 const InputDecoration(labelText: 'Theme'), | ||||
|                             value: settingsProvider.theme, | ||||
|                             items: const [ | ||||
|                               DropdownMenuItem( | ||||
| @@ -49,7 +62,8 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                         DropdownButtonFormField( | ||||
|                       decoration: const InputDecoration(labelText: 'Colour'), | ||||
|                             decoration: | ||||
|                                 const InputDecoration(labelText: 'Colour'), | ||||
|                             value: settingsProvider.colour, | ||||
|                             items: const [ | ||||
|                               DropdownMenuItem( | ||||
| @@ -66,21 +80,153 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                                 settingsProvider.colour = value; | ||||
|                               } | ||||
|                             }), | ||||
|                         const SizedBox( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                         Row( | ||||
|                           mainAxisAlignment: MainAxisAlignment.start, | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Expanded( | ||||
|                                 child: 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( | ||||
|                               width: 16, | ||||
|                             ), | ||||
|                             Expanded( | ||||
|                                 child: 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 Divider( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                         const SizedBox( | ||||
|                           height: 16, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'More', | ||||
|                           style: TextStyle( | ||||
|                               color: Theme.of(context).colorScheme.primary), | ||||
|                         ), | ||||
|                         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 Spacer(), | ||||
|                         Row( | ||||
|                     mainAxisAlignment: MainAxisAlignment.end, | ||||
|                           mainAxisAlignment: MainAxisAlignment.center, | ||||
|                           children: [ | ||||
|                       ElevatedButton.icon( | ||||
|                             TextButton.icon( | ||||
|                               style: ButtonStyle( | ||||
|                                 foregroundColor: | ||||
|                                     MaterialStateProperty.resolveWith<Color>( | ||||
|                                         (Set<MaterialState> states) { | ||||
|                                   return Colors.grey; | ||||
|                                 }), | ||||
|                               ), | ||||
|                               onPressed: () { | ||||
|                                 launchUrlString(settingsProvider.sourceUrl, | ||||
|                                     mode: LaunchMode.externalApplication); | ||||
|                               }, | ||||
|                               icon: const Icon(Icons.code), | ||||
|                         label: const Text('Source'), | ||||
|                               label: Text( | ||||
|                                 'Source', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall, | ||||
|                               ), | ||||
|                             ) | ||||
|                           ], | ||||
|                         ), | ||||
|                       ], | ||||
|               )); | ||||
|                     ))) | ||||
|     ]); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										390
									
								
								lib/providers/apps_provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,390 @@ | ||||
| // 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: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               HapticFeedback.selectionClick(); | ||||
|               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: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Cancel')), | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|               HapticFeedback.selectionClick(); | ||||
|               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(), | ||||
|     IzzyOnDroid(), | ||||
|     Mullvad(), | ||||
|     Signal() | ||||
|   ]; | ||||
|  | ||||
|   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); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										145
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,13 @@ | ||||
| # Generated by pub | ||||
| # See https://dart.dev/tools/pub/glossary#lockfile | ||||
| packages: | ||||
|   animations: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: animations | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.4" | ||||
|   archive: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -91,14 +98,56 @@ packages: | ||||
|       name: dbus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     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: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: dynamic_color | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.5.3" | ||||
|     version: "1.5.4" | ||||
|   fake_async: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -119,7 +168,14 @@ packages: | ||||
|       name: file | ||||
|       url: "https://pub.dartlang.org" | ||||
|     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: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -152,21 +208,28 @@ packages: | ||||
|       name: flutter_local_notifications | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "9.7.0" | ||||
|     version: "10.0.0" | ||||
|   flutter_local_notifications_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_local_notifications_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.5.1" | ||||
|     version: "1.0.0" | ||||
|   flutter_local_notifications_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: flutter_local_notifications_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.0.0" | ||||
|     version: "6.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: | ||||
|     dependency: "direct dev" | ||||
|     description: flutter | ||||
| @@ -177,6 +240,13 @@ packages: | ||||
|     description: flutter | ||||
|     source: sdk | ||||
|     version: "0.0.0" | ||||
|   fluttertoast: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: fluttertoast | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "8.0.9" | ||||
|   html: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -281,7 +351,7 @@ packages: | ||||
|       name: path_provider_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.17" | ||||
|     version: "2.0.20" | ||||
|   path_provider_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -316,7 +386,42 @@ packages: | ||||
|       name: path_provider_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.2" | ||||
|     version: "2.1.3" | ||||
|   permission_handler: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: permission_handler | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|   permission_handler_android: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "10.0.0" | ||||
|   permission_handler_apple: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_apple | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "9.0.4" | ||||
|   permission_handler_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.7.0" | ||||
|   permission_handler_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: permission_handler_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.1.0" | ||||
|   petitparser: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -337,7 +442,7 @@ packages: | ||||
|       name: plugin_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.2" | ||||
|     version: "2.1.3" | ||||
|   process: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -365,7 +470,7 @@ packages: | ||||
|       name: shared_preferences_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.12" | ||||
|     version: "2.0.13" | ||||
|   shared_preferences_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -393,7 +498,7 @@ packages: | ||||
|       name: shared_preferences_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.0" | ||||
|     version: "2.1.0" | ||||
|   shared_preferences_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -454,7 +559,7 @@ packages: | ||||
|       name: test_api | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.4.12" | ||||
|     version: "0.4.14" | ||||
|   timezone: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -482,7 +587,7 @@ packages: | ||||
|       name: url_launcher_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.17" | ||||
|     version: "6.0.19" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -531,7 +636,7 @@ packages: | ||||
|       name: vector_math | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.1.2" | ||||
|     version: "2.1.3" | ||||
|   webview_flutter: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -545,28 +650,28 @@ packages: | ||||
|       name: webview_flutter_android | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.9.5" | ||||
|     version: "2.10.1" | ||||
|   webview_flutter_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.9.1" | ||||
|     version: "1.9.3" | ||||
|   webview_flutter_wkwebview: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: webview_flutter_wkwebview | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.9.3" | ||||
|     version: "2.9.4" | ||||
|   win32: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: win32 | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.7.0" | ||||
|     version: "3.0.0" | ||||
|   workmanager: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
| @@ -580,7 +685,7 @@ packages: | ||||
|       name: xdg_directories | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.0+1" | ||||
|     version: "0.2.0+2" | ||||
|   xml: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -597,4 +702,4 @@ packages: | ||||
|     version: "3.1.1" | ||||
| sdks: | ||||
|   dart: ">=2.19.0-79.0.dev <3.0.0" | ||||
|   flutter: ">=3.1.0-0.0.pre.1036" | ||||
|   flutter: ">=3.3.0" | ||||
|   | ||||
							
								
								
									
										19
									
								
								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 | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.1.0+1 | ||||
| version: 0.2.2+13 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||
| @@ -35,19 +35,24 @@ dependencies: | ||||
|  | ||||
|   # The following adds the Cupertino Icons font to your application. | ||||
|   # Use with the CupertinoIcons class for iOS style icons. | ||||
|   cupertino_icons: ^1.0.2 | ||||
|   cupertino_icons: ^1.0.5 | ||||
|   path_provider: ^2.0.11 | ||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||
|   flutter_local_notifications: ^9.7.0 | ||||
|   flutter_local_notifications: ^10.0.0 | ||||
|   provider: ^6.0.3 | ||||
|   http: ^0.13.5 | ||||
|   webview_flutter: ^3.0.4 | ||||
|   workmanager: ^0.5.0 | ||||
|   dynamic_color: ^1.5.3 | ||||
|   dynamic_color: ^1.5.4 | ||||
|   install_plugin_v2: ^1.0.0 # Try replacing this | ||||
|   html: ^0.15.0 | ||||
|   shared_preferences: ^2.0.15 | ||||
|   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 | ||||
|   animations: ^2.0.4 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
| @@ -60,13 +65,13 @@ dev_dependencies: | ||||
|   # activated in the `analysis_options.yaml` file located at the root of your | ||||
|   # package. See that file for information about deactivating specific lint | ||||
|   # rules and activating additional ones. | ||||
|   flutter_lints: ^2.0.0 | ||||
|   flutter_lints: ^2.0.1 | ||||
|  | ||||
| flutter_icons: | ||||
|   android: true | ||||
|   image_path: "assets/icon.png" | ||||
|   image_path: "assets/graphics/icon.png" | ||||
|   adaptive_icon_background: "#FFFFFF" | ||||
|   adaptive_icon_foreground: "assets/icon.png" | ||||
|   adaptive_icon_foreground: "assets/graphics/icon.png" | ||||
|  | ||||
| # For information on the generic Dart part of this file, see the | ||||
| # following page: https://dart.dev/tools/pub/pubspec | ||||
|   | ||||