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. | Get Android App Updates Directly From the Source. | ||||||
|  |  | ||||||
| Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available. | ||||||
|  |  | ||||||
| Currently supported App sources: |  | ||||||
| - GitHub |  | ||||||
|  |  | ||||||
| Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) | Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0) | ||||||
|  |  | ||||||
| ***Work In Progress - Far from ready.*** | Currently supported App sources: | ||||||
|  | - [GitHub](https://github.com/) | ||||||
|  | - [GitLab](https://gitlab.com/) | ||||||
|  | - [F-Droid](https://f-droid.org/) | ||||||
|  | - [IzzyOnDroid](https://android.izzysoft.de/) | ||||||
|  | - [Mullvad](https://mullvad.net/en/) | ||||||
|  | - [Signal](https://signal.org/) | ||||||
|  |  | ||||||
| ## Limitations | ## Limitations | ||||||
| - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. | ||||||
| - Already installed apps are not detected, for the above reason along with the fact that App sources do not provide App IDs (like `org.example.app`) to allow for comparisons. |  | ||||||
| - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. | - Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin. | ||||||
|  | - For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable. | ||||||
|  |  | ||||||
|  | ## Screenshots | ||||||
|  |  | ||||||
|  | | <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" />           | <img src="./assets/screenshots/3.material_you.png" alt="Material You" />    | | ||||||
|  | | ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | ||||||
|  | | <img src="./assets/screenshots/4.app.png" alt="App Page" />   | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> | | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) { | |||||||
| } | } | ||||||
|  |  | ||||||
| android { | android { | ||||||
|     compileSdkVersion flutter.compileSdkVersion |     compileSdkVersion 33 | ||||||
|     ndkVersion flutter.ndkVersion |     ndkVersion flutter.ndkVersion | ||||||
|  |  | ||||||
|     compileOptions { |     compileOptions { | ||||||
| @@ -54,7 +54,7 @@ android { | |||||||
|         // You can update the following values to match your application needs. |         // You can update the following values to match your application needs. | ||||||
|         // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. |         // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. | ||||||
|         minSdkVersion 23 |         minSdkVersion 23 | ||||||
|         targetSdkVersion 32 |         targetSdkVersion 33 | ||||||
|         versionCode flutterVersionCode.toInteger() |         versionCode flutterVersionCode.toInteger() | ||||||
|         versionName flutterVersionName |         versionName flutterVersionName | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable/ic_notification.png
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.0 KiB | 
							
								
								
									
										3
									
								
								android/app/src/main/res/raw/keep.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <resources xmlns:tools="http://schemas.android.com/tools" | ||||||
|  |     tools:keep="@drawable/*" /> | ||||||
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/banner.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 66 KiB | 
| Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/icon.psd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/obtainium.psd
									
									
									
									
									
										Executable file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								assets/graphics/store-icon.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 21 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/1.apps.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 99 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/2.dark_theme.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 83 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/3.material_you.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 86 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/4.app.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 263 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/5.apk_picker.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 200 KiB | 
							
								
								
									
										
											BIN
										
									
								
								assets/screenshots/6.apk_install.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 192 KiB | 
							
								
								
									
										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/material.dart'; | ||||||
| import 'package:flutter/services.dart'; | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/home.dart'; | import 'package:obtainium/pages/home.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/services/settings_provider.dart'; | import 'package:obtainium/providers/notifications_provider.dart'; | ||||||
| import 'package:obtainium/services/source_service.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
|  | import 'package:permission_handler/permission_handler.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:workmanager/workmanager.dart'; | import 'package:workmanager/workmanager.dart'; | ||||||
| import 'package:dynamic_color/dynamic_color.dart'; | import 'package:dynamic_color/dynamic_color.dart'; | ||||||
|  | import 'package:device_info_plus/device_info_plus.dart'; | ||||||
|  |  | ||||||
| void backgroundUpdateCheck() { | const String currentReleaseTag = | ||||||
|   Workmanager().executeTask((task, inputData) async { |     'v0.2.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||||
|     var appsProvider = AppsProvider(bg: true); |  | ||||||
|  | @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(); |       await appsProvider.loadApps(); | ||||||
|     List<App> updates = await appsProvider.getUpdates(); |       List<App> updates = await appsProvider.checkUpdates(); | ||||||
|       if (updates.isNotEmpty) { |       if (updates.isNotEmpty) { | ||||||
|       String message = updates.length == 1 |         notificationsProvider.notify(UpdateNotification(updates), | ||||||
|           ? '${updates[0].name} has an update.' |             cancelExisting: true); | ||||||
|           : '${(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'); |  | ||||||
|       } |       } | ||||||
|       return Future.value(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 { | void main() async { | ||||||
|   WidgetsFlutterBinding.ensureInitialized(); |   WidgetsFlutterBinding.ensureInitialized(); | ||||||
|  |   if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) { | ||||||
|     SystemChrome.setSystemUIOverlayStyle( |     SystemChrome.setSystemUIOverlayStyle( | ||||||
|       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), |       const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), | ||||||
|     ); |     ); | ||||||
|     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); |     SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); | ||||||
|  |   } | ||||||
|   Workmanager().initialize( |   Workmanager().initialize( | ||||||
|     backgroundUpdateCheck, |     bgTaskCallback, | ||||||
|   ); |   ); | ||||||
|   await Workmanager().cancelByUniqueName('update-apps-task'); |  | ||||||
|   await Workmanager().registerPeriodicTask( |  | ||||||
|       'update-apps-task', 'backgroundUpdateCheck', |  | ||||||
|       frequency: const Duration(minutes: 15), |  | ||||||
|       initialDelay: const Duration(minutes: 15), |  | ||||||
|       constraints: Constraints(networkType: NetworkType.connected)); |  | ||||||
|   runApp(MultiProvider( |   runApp(MultiProvider( | ||||||
|     providers: [ |     providers: [ | ||||||
|       ChangeNotifierProvider(create: (context) => AppsProvider()), |       ChangeNotifierProvider( | ||||||
|       ChangeNotifierProvider(create: (context) => SettingsProvider()) |           create: (context) => AppsProvider( | ||||||
|  |               shouldLoadApps: true, | ||||||
|  |               shouldCheckUpdatesAfterLoad: true, | ||||||
|  |               shouldDeleteAPKs: true)), | ||||||
|  |       ChangeNotifierProvider(create: (context) => SettingsProvider()), | ||||||
|  |       Provider(create: (context) => NotificationsProvider()) | ||||||
|     ], |     ], | ||||||
|     child: const MyApp(), |     child: const MyApp(), | ||||||
|   )); |   )); | ||||||
| @@ -61,31 +74,41 @@ class MyApp extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     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>(); |     SettingsProvider settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     AppsProvider appsProvider = context.read<AppsProvider>(); | ||||||
|  |  | ||||||
|     if (settingsProvider.prefs == null) { |     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(); |       bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); | ||||||
|       if (isFirstRun) { |       if (isFirstRun) { | ||||||
|             AppsProvider appsProvider = context.read<AppsProvider>(); |         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||||
|             appsProvider |         Permission.notification.request(); | ||||||
|                 .notify( |         appsProvider.saveApp(App( | ||||||
|                     3, |             'imranr98_obtainium_${GitHub().host}', | ||||||
|                     'Permission Notification', |             'https://github.com/ImranR98/Obtainium', | ||||||
|                     'This is a transient notification used to trigger the Android 13 notification permission prompt', |             'ImranR98', | ||||||
|                     'PERMISSION_NOTIFICATION', |             'Obtainium', | ||||||
|                     'Permission Notifications', |             currentReleaseTag, | ||||||
|                     'A transient notification used to trigger the Android 13 notification permission prompt', |             currentReleaseTag, | ||||||
|                     important: false) |             [], | ||||||
|                 .whenComplete(() { |             0)); | ||||||
|               appsProvider.downloaderNotifications.cancel(3); |  | ||||||
|             }); |  | ||||||
|       } |       } | ||||||
|         }); |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     return DynamicColorBuilder( | ||||||
|  |         builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { | ||||||
|  |       // Decide on a colour/brightness scheme based on OS and user settings | ||||||
|       ColorScheme lightColorScheme; |       ColorScheme lightColorScheme; | ||||||
|       ColorScheme darkColorScheme; |       ColorScheme darkColorScheme; | ||||||
|       if (lightDynamic != null && |       if (lightDynamic != null && | ||||||
| @@ -98,7 +121,6 @@ class MyApp extends StatelessWidget { | |||||||
|         darkColorScheme = ColorScheme.fromSeed( |         darkColorScheme = ColorScheme.fromSeed( | ||||||
|             seedColor: defaultThemeColour, brightness: Brightness.dark); |             seedColor: defaultThemeColour, brightness: Brightness.dark); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       return MaterialApp( |       return MaterialApp( | ||||||
|           title: 'Obtainium', |           title: 'Obtainium', | ||||||
|           theme: ThemeData( |           theme: ThemeData( | ||||||
| @@ -111,7 +133,8 @@ class MyApp extends StatelessWidget { | |||||||
|               useMaterial3: true, |               useMaterial3: true, | ||||||
|               colorScheme: settingsProvider.theme == ThemeSettings.light |               colorScheme: settingsProvider.theme == ThemeSettings.light | ||||||
|                   ? lightColorScheme |                   ? lightColorScheme | ||||||
|                   : darkColorScheme), |                   : darkColorScheme, | ||||||
|  |               fontFamily: 'Metropolis'), | ||||||
|           home: const HomePage()); |           home: const HomePage()); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,8 +1,12 @@ | |||||||
| import 'package:flutter/material.dart'; | 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/pages/app.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
| import 'package:obtainium/services/source_service.dart'; | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/source_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| class AddAppPage extends StatefulWidget { | class AddAppPage extends StatefulWidget { | ||||||
|   const AddAppPage({super.key}); |   const AddAppPage({super.key}); | ||||||
| @@ -18,16 +22,25 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   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( |             child: Form( | ||||||
|                 key: _formKey, |                 key: _formKey, | ||||||
|                 child: Column( |                 child: Column( | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, |                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                   children: [ |                   children: [ | ||||||
|           const Spacer(), |                     Container(), | ||||||
|                     Padding( |                     Padding( | ||||||
|               padding: const EdgeInsets.symmetric(horizontal: 16.0), |                       padding: const EdgeInsets.all(16), | ||||||
|               child: TextFormField( |                       child: Column( | ||||||
|  |                         crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                         children: [ | ||||||
|  |                           TextFormField( | ||||||
|                             decoration: const InputDecoration( |                             decoration: const InputDecoration( | ||||||
|                                 hintText: 'https://github.com/Author/Project', |                                 hintText: 'https://github.com/Author/Project', | ||||||
|                                 helperText: 'Enter the App source URL'), |                                 helperText: 'Enter the App source URL'), | ||||||
| @@ -40,36 +53,48 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                               } |                               } | ||||||
|                               return null; |                               return null; | ||||||
|                             }, |                             }, | ||||||
|               )), |                           ), | ||||||
|                           Padding( |                           Padding( | ||||||
|             padding: |                             padding: const EdgeInsets.symmetric(vertical: 16.0), | ||||||
|                 const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), |  | ||||||
|                             child: ElevatedButton( |                             child: ElevatedButton( | ||||||
|                               onPressed: gettingAppInfo |                               onPressed: gettingAppInfo | ||||||
|                                   ? null |                                   ? null | ||||||
|                                   : () { |                                   : () { | ||||||
|  |                                       HapticFeedback.selectionClick(); | ||||||
|                                       if (_formKey.currentState!.validate()) { |                                       if (_formKey.currentState!.validate()) { | ||||||
|                                         setState(() { |                                         setState(() { | ||||||
|                                           gettingAppInfo = true; |                                           gettingAppInfo = true; | ||||||
|                                         }); |                                         }); | ||||||
|                         SourceService() |                                         sourceProvider | ||||||
|                             .getApp(urlInputController.value.text) |                                             .getApp( | ||||||
|  |                                                 urlInputController.value.text) | ||||||
|                                             .then((app) { |                                             .then((app) { | ||||||
|                           var appsProvider = context.read<AppsProvider>(); |                                           var appsProvider = | ||||||
|                           if (appsProvider.apps.containsKey(app.id)) { |                                               context.read<AppsProvider>(); | ||||||
|  |                                           var settingsProvider = | ||||||
|  |                                               context.read<SettingsProvider>(); | ||||||
|  |                                           if (appsProvider.apps | ||||||
|  |                                               .containsKey(app.id)) { | ||||||
|                                             throw 'App already added'; |                                             throw 'App already added'; | ||||||
|                                           } |                                           } | ||||||
|  |                                           settingsProvider | ||||||
|  |                                               .getInstallPermission() | ||||||
|  |                                               .then((_) { | ||||||
|                                             appsProvider.saveApp(app).then((_) { |                                             appsProvider.saveApp(app).then((_) { | ||||||
|                                               urlInputController.clear(); |                                               urlInputController.clear(); | ||||||
|                                               Navigator.push( |                                               Navigator.push( | ||||||
|                                                   context, |                                                   context, | ||||||
|                                                   MaterialPageRoute( |                                                   MaterialPageRoute( | ||||||
|                                                       builder: (context) => |                                                       builder: (context) => | ||||||
|                                         AppPage(appId: app.id))); |                                                           AppPage( | ||||||
|  |                                                               appId: app.id))); | ||||||
|  |                                             }); | ||||||
|                                           }); |                                           }); | ||||||
|                                         }).catchError((e) { |                                         }).catchError((e) { | ||||||
|                           ScaffoldMessenger.of(context).showSnackBar( |                                           ScaffoldMessenger.of(context) | ||||||
|                             SnackBar(content: Text(e.toString())), |                                               .showSnackBar( | ||||||
|  |                                             SnackBar( | ||||||
|  |                                                 content: Text(e.toString())), | ||||||
|                                           ); |                                           ); | ||||||
|                                         }).whenComplete(() { |                                         }).whenComplete(() { | ||||||
|                                           setState(() { |                                           setState(() { | ||||||
| @@ -81,10 +106,42 @@ class _AddAppPageState extends State<AddAppPage> { | |||||||
|                               child: const Text('Add'), |                               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: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:webview_flutter/webview_flutter.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
| @@ -16,17 +20,70 @@ class _AppPageState extends State<AppPage> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|  |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|     AppInMemory? app = appsProvider.apps[widget.appId]; |     AppInMemory? app = appsProvider.apps[widget.appId]; | ||||||
|     if (app?.app.installedVersion != null) { |     if (app?.app.installedVersion != null) { | ||||||
|       appsProvider.getUpdate(app!.app.id); |       appsProvider.getUpdate(app!.app.id); | ||||||
|     } |     } | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|       appBar: AppBar( |       backgroundColor: Theme.of(context).colorScheme.surface, | ||||||
|         title: Text('${app?.app.author}/${app?.app.name}'), |       body: CustomScrollView(slivers: <Widget>[ | ||||||
|       ), |         CustomAppBar(title: '${app?.app.name}'), | ||||||
|       body: WebView( |         SliverFillRemaining( | ||||||
|  |           child: settingsProvider.showAppWebpage | ||||||
|  |               ? WebView( | ||||||
|                   initialUrl: app?.app.url, |                   initialUrl: app?.app.url, | ||||||
|  |                   javascriptMode: JavascriptMode.unrestricted, | ||||||
|  |                 ) | ||||||
|  |               : Column( | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                   children: [ | ||||||
|  |                     Text( | ||||||
|  |                       app?.app.name ?? 'App', | ||||||
|  |                       textAlign: TextAlign.center, | ||||||
|  |                       style: Theme.of(context).textTheme.displayLarge, | ||||||
|                     ), |                     ), | ||||||
|  |                     Text( | ||||||
|  |                       'By ${app?.app.author ?? 'Unknown'}', | ||||||
|  |                       textAlign: TextAlign.center, | ||||||
|  |                       style: Theme.of(context).textTheme.headlineMedium, | ||||||
|  |                     ), | ||||||
|  |                     const SizedBox( | ||||||
|  |                       height: 32, | ||||||
|  |                     ), | ||||||
|  |                     GestureDetector( | ||||||
|  |                         onTap: () { | ||||||
|  |                           if (app?.app.url != null) { | ||||||
|  |                             launchUrlString(app?.app.url ?? '', | ||||||
|  |                                 mode: LaunchMode.externalApplication); | ||||||
|  |                           } | ||||||
|  |                         }, | ||||||
|  |                         child: Text( | ||||||
|  |                           app?.app.url ?? '', | ||||||
|  |                           textAlign: TextAlign.center, | ||||||
|  |                           style: const TextStyle( | ||||||
|  |                               decoration: TextDecoration.underline, | ||||||
|  |                               fontStyle: FontStyle.italic, | ||||||
|  |                               fontSize: 12), | ||||||
|  |                         )), | ||||||
|  |                     const SizedBox( | ||||||
|  |                       height: 32, | ||||||
|  |                     ), | ||||||
|  |                     Text( | ||||||
|  |                       'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}', | ||||||
|  |                       textAlign: TextAlign.center, | ||||||
|  |                       style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |                     ), | ||||||
|  |                     Text( | ||||||
|  |                       'Installed Version: ${app?.app.installedVersion ?? 'None'}', | ||||||
|  |                       textAlign: TextAlign.center, | ||||||
|  |                       style: Theme.of(context).textTheme.bodyLarge, | ||||||
|  |                     ), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |         ), | ||||||
|  |       ]), | ||||||
|       bottomSheet: Padding( |       bottomSheet: Padding( | ||||||
|           padding: EdgeInsets.fromLTRB( |           padding: EdgeInsets.fromLTRB( | ||||||
|               0, 0, 0, MediaQuery.of(context).padding.bottom), |               0, 0, 0, MediaQuery.of(context).padding.bottom), | ||||||
| @@ -38,17 +95,93 @@ class _AppPageState extends State<AppPage> { | |||||||
|                   child: Row( |                   child: Row( | ||||||
|                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, |                       mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||||
|                       children: [ |                       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( |                         Expanded( | ||||||
|                             child: ElevatedButton( |                             child: ElevatedButton( | ||||||
|                                 onPressed: (app?.app.installedVersion == null || |                                 onPressed: (app?.app.installedVersion == null || | ||||||
|                                             appsProvider |                                             appsProvider | ||||||
|                                                 .checkAppObjectForUpdate( |                                                 .checkAppObjectForUpdate( | ||||||
|                                                     app!.app)) && |                                                     app!.app)) && | ||||||
|                                         app?.downloadProgress == null |                                         !appsProvider.areDownloadsRunning() | ||||||
|                                     ? () { |                                     ? () { | ||||||
|  |                                         HapticFeedback.heavyImpact(); | ||||||
|                                         appsProvider |                                         appsProvider | ||||||
|                                             .downloadAndInstallLatestApp( |                                             .downloadAndInstallLatestApp( | ||||||
|                                                 app!.app.id); |                                                 [app!.app.id], | ||||||
|  |                                                 context).then((res) { | ||||||
|  |                                           if (res && mounted) { | ||||||
|  |                                             Navigator.of(context).pop(); | ||||||
|  |                                           } | ||||||
|  |                                         }); | ||||||
|                                       } |                                       } | ||||||
|                                     : null, |                                     : null, | ||||||
|                                 child: Text(app?.app.installedVersion == null |                                 child: Text(app?.app.installedVersion == null | ||||||
| @@ -69,6 +202,8 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                           actions: [ |                                           actions: [ | ||||||
|                                             TextButton( |                                             TextButton( | ||||||
|                                                 onPressed: () { |                                                 onPressed: () { | ||||||
|  |                                                   HapticFeedback | ||||||
|  |                                                       .selectionClick(); | ||||||
|                                                   appsProvider |                                                   appsProvider | ||||||
|                                                       .removeApp(app!.app.id) |                                                       .removeApp(app!.app.id) | ||||||
|                                                       .then((_) { |                                                       .then((_) { | ||||||
| @@ -89,8 +224,10 @@ class _AppPageState extends State<AppPage> { | |||||||
|                                       }); |                                       }); | ||||||
|                                 }, |                                 }, | ||||||
|                           style: TextButton.styleFrom( |                           style: TextButton.styleFrom( | ||||||
|                               foregroundColor: Theme.of(context).errorColor, |                               foregroundColor: | ||||||
|                               surfaceTintColor: Theme.of(context).errorColor), |                                   Theme.of(context).colorScheme.error, | ||||||
|  |                               surfaceTintColor: | ||||||
|  |                                   Theme.of(context).colorScheme.error), | ||||||
|                           child: const Text('Remove'), |                           child: const Text('Remove'), | ||||||
|                         ), |                         ), | ||||||
|                       ])), |                       ])), | ||||||
|   | |||||||
| @@ -1,6 +1,9 @@ | |||||||
| import 'package:flutter/material.dart'; | 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/pages/app.dart'; | ||||||
| import 'package:obtainium/services/apps_provider.dart'; | import 'package:obtainium/providers/apps_provider.dart'; | ||||||
|  | import 'package:obtainium/providers/settings_provider.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
|  |  | ||||||
| class AppsPage extends StatefulWidget { | class AppsPage extends StatefulWidget { | ||||||
| @@ -14,31 +17,71 @@ class _AppsPageState extends State<AppsPage> { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     var appsProvider = context.watch<AppsProvider>(); |     var appsProvider = context.watch<AppsProvider>(); | ||||||
|     appsProvider.getUpdates(); |     var settingsProvider = context.watch<SettingsProvider>(); | ||||||
|  |     var existingUpdateAppIds = appsProvider.getExistingUpdates(); | ||||||
|  |     var sortedApps = appsProvider.apps.values.toList(); | ||||||
|  |     sortedApps.sort((a, b) { | ||||||
|  |       int result = 0; | ||||||
|  |       if (settingsProvider.sortColumn == SortColumnSettings.authorName) { | ||||||
|  |         result = | ||||||
|  |             (a.app.author + a.app.name).compareTo(b.app.author + b.app.name); | ||||||
|  |       } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { | ||||||
|  |         result = | ||||||
|  |             (a.app.name + a.app.author).compareTo(b.app.name + b.app.author); | ||||||
|  |       } | ||||||
|  |       return result; | ||||||
|  |     }); | ||||||
|  |     if (settingsProvider.sortOrder == SortOrderSettings.ascending) { | ||||||
|  |       sortedApps = sortedApps.reversed.toList(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return Center( |     return Scaffold( | ||||||
|  |         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 |                         child: appsProvider.loadingApps | ||||||
|                             ? const CircularProgressIndicator() |                             ? const CircularProgressIndicator() | ||||||
|           : appsProvider.apps.isEmpty |                             : Text( | ||||||
|               ? Text( |  | ||||||
|                                 'No Apps', |                                 'No Apps', | ||||||
|                   style: Theme.of(context).textTheme.headline4, |                                 style: | ||||||
|                 ) |                                     Theme.of(context).textTheme.headlineMedium, | ||||||
|               : RefreshIndicator( |                               ))), | ||||||
|                   onRefresh: appsProvider.getUpdates, |               SliverList( | ||||||
|                   child: ListView( |                   delegate: SliverChildBuilderDelegate( | ||||||
|                     children: appsProvider.apps.values |                       (BuildContext context, int index) { | ||||||
|                         .map( |                 return ListTile( | ||||||
|                           (e) => ListTile( |                   title: Text( | ||||||
|                             title: Text('${e.app.author}/${e.app.name}'), |                       '${sortedApps[index].app.author}/${sortedApps[index].app.name}'), | ||||||
|                             subtitle: |                   subtitle: Text(sortedApps[index].app.installedVersion ?? | ||||||
|                                 Text(e.app.installedVersion ?? 'Not Installed'), |                       'Not Installed'), | ||||||
|                             trailing: e.downloadProgress != null |                   trailing: sortedApps[index].downloadProgress != null | ||||||
|                       ? Text( |                       ? Text( | ||||||
|                                     'Downloading - ${e.downloadProgress!.toInt()}%') |                           'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') | ||||||
|                                 : (e.app.installedVersion != null && |                       : (sortedApps[index].app.installedVersion != null && | ||||||
|                                         e.app.installedVersion != |                               sortedApps[index].app.installedVersion != | ||||||
|                                             e.app.latestVersion |                                   sortedApps[index].app.latestVersion | ||||||
|                           ? const Text('Update Available') |                           ? const Text('Update Available') | ||||||
|                           : null), |                           : null), | ||||||
|                   onTap: () { |                   onTap: () { | ||||||
| @@ -46,14 +89,11 @@ class _AppsPageState extends State<AppsPage> { | |||||||
|                       context, |                       context, | ||||||
|                       MaterialPageRoute( |                       MaterialPageRoute( | ||||||
|                           builder: (context) => |                           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/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:obtainium/pages/add_app.dart'; | import 'package:obtainium/pages/add_app.dart'; | ||||||
| import 'package:obtainium/pages/apps.dart'; | import 'package:obtainium/pages/apps.dart'; | ||||||
|  | import 'package:obtainium/pages/import_export.dart'; | ||||||
| import 'package:obtainium/pages/settings.dart'; | import 'package:obtainium/pages/settings.dart'; | ||||||
|  |  | ||||||
| class HomePage extends StatefulWidget { | class HomePage extends StatefulWidget { | ||||||
| @@ -10,32 +13,82 @@ class HomePage extends StatefulWidget { | |||||||
|   State<HomePage> createState() => _HomePageState(); |   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> { | class _HomePageState extends State<HomePage> { | ||||||
|   int selectedIndex = 1; |   List<int> selectedIndexHistory = []; | ||||||
|   List<Widget> pages = [ |  | ||||||
|     const SettingsPage(), |   List<NavigationPageItem> pages = [ | ||||||
|     const AppsPage(), |     NavigationPageItem('Apps', Icons.apps, const AppsPage()), | ||||||
|     const AddAppPage() |     NavigationPageItem('Add App', Icons.add, const AddAppPage()), | ||||||
|  |     NavigationPageItem( | ||||||
|  |         'Import/Export', Icons.import_export, const ImportExportPage()), | ||||||
|  |     NavigationPageItem('Settings', Icons.settings, const SettingsPage()) | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return Scaffold( |     return WillPopScope( | ||||||
|       appBar: AppBar(title: const Text('Obtainium')), |         child: Scaffold( | ||||||
|       body: pages.elementAt(selectedIndex), |           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( |           bottomNavigationBar: NavigationBar( | ||||||
|         destinations: const [ |             destinations: pages | ||||||
|           NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), |                 .map((e) => | ||||||
|           NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), |                     NavigationDestination(icon: Icon(e.icon), label: e.title)) | ||||||
|           NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), |                 .toList(), | ||||||
|         ], |  | ||||||
|             onDestinationSelected: (int index) { |             onDestinationSelected: (int index) { | ||||||
|  |               HapticFeedback.selectionClick(); | ||||||
|               setState(() { |               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: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:provider/provider.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
| @@ -17,14 +19,25 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|     if (settingsProvider.prefs == null) { |     if (settingsProvider.prefs == null) { | ||||||
|       settingsProvider.initializeSettings(); |       settingsProvider.initializeSettings(); | ||||||
|     } |     } | ||||||
|     return Padding( |     return CustomScrollView(slivers: <Widget>[ | ||||||
|  |       const CustomAppBar(title: 'Add App'), | ||||||
|  |       SliverFillRemaining( | ||||||
|  |           hasScrollBody: true, | ||||||
|  |           child: Padding( | ||||||
|               padding: const EdgeInsets.all(16), |               padding: const EdgeInsets.all(16), | ||||||
|               child: settingsProvider.prefs == null |               child: settingsProvider.prefs == null | ||||||
|                   ? Container() |                   ? Container() | ||||||
|                   : Column( |                   : Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                       children: [ |                       children: [ | ||||||
|  |                         Text( | ||||||
|  |                           'Appearance', | ||||||
|  |                           style: TextStyle( | ||||||
|  |                               color: Theme.of(context).colorScheme.primary), | ||||||
|  |                         ), | ||||||
|                         DropdownButtonFormField( |                         DropdownButtonFormField( | ||||||
|                       decoration: const InputDecoration(labelText: 'Theme'), |                             decoration: | ||||||
|  |                                 const InputDecoration(labelText: 'Theme'), | ||||||
|                             value: settingsProvider.theme, |                             value: settingsProvider.theme, | ||||||
|                             items: const [ |                             items: const [ | ||||||
|                               DropdownMenuItem( |                               DropdownMenuItem( | ||||||
| @@ -49,7 +62,8 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                           height: 16, |                           height: 16, | ||||||
|                         ), |                         ), | ||||||
|                         DropdownButtonFormField( |                         DropdownButtonFormField( | ||||||
|                       decoration: const InputDecoration(labelText: 'Colour'), |                             decoration: | ||||||
|  |                                 const InputDecoration(labelText: 'Colour'), | ||||||
|                             value: settingsProvider.colour, |                             value: settingsProvider.colour, | ||||||
|                             items: const [ |                             items: const [ | ||||||
|                               DropdownMenuItem( |                               DropdownMenuItem( | ||||||
| @@ -66,21 +80,153 @@ class _SettingsPageState extends State<SettingsPage> { | |||||||
|                                 settingsProvider.colour = value; |                                 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(), |                         const Spacer(), | ||||||
|                         Row( |                         Row( | ||||||
|                     mainAxisAlignment: MainAxisAlignment.end, |                           mainAxisAlignment: MainAxisAlignment.center, | ||||||
|                           children: [ |                           children: [ | ||||||
|                       ElevatedButton.icon( |                             TextButton.icon( | ||||||
|  |                               style: ButtonStyle( | ||||||
|  |                                 foregroundColor: | ||||||
|  |                                     MaterialStateProperty.resolveWith<Color>( | ||||||
|  |                                         (Set<MaterialState> states) { | ||||||
|  |                                   return Colors.grey; | ||||||
|  |                                 }), | ||||||
|  |                               ), | ||||||
|                               onPressed: () { |                               onPressed: () { | ||||||
|                                 launchUrlString(settingsProvider.sourceUrl, |                                 launchUrlString(settingsProvider.sourceUrl, | ||||||
|                                     mode: LaunchMode.externalApplication); |                                     mode: LaunchMode.externalApplication); | ||||||
|                               }, |                               }, | ||||||
|                               icon: const Icon(Icons.code), |                               icon: const Icon(Icons.code), | ||||||
|                         label: const Text('Source'), |                               label: Text( | ||||||
|  |                                 'Source', | ||||||
|  |                                 style: Theme.of(context).textTheme.bodySmall, | ||||||
|  |                               ), | ||||||
|                             ) |                             ) | ||||||
|                           ], |                           ], | ||||||
|                         ), |                         ), | ||||||
|                       ], |                       ], | ||||||
|               )); |                     ))) | ||||||
|  |     ]); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										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 | # Generated by pub | ||||||
| # See https://dart.dev/tools/pub/glossary#lockfile | # See https://dart.dev/tools/pub/glossary#lockfile | ||||||
| packages: | packages: | ||||||
|  |   animations: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: animations | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "2.0.4" | ||||||
|   archive: |   archive: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -91,14 +98,56 @@ packages: | |||||||
|       name: dbus |       name: dbus | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.7.7" |     version: "0.7.8" | ||||||
|  |   device_info_plus: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "4.1.2" | ||||||
|  |   device_info_plus_linux: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_linux | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_macos: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_macos | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_platform_interface | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_web: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_web | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.0.0" | ||||||
|  |   device_info_plus_windows: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: device_info_plus_windows | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "4.1.0" | ||||||
|   dynamic_color: |   dynamic_color: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: dynamic_color |       name: dynamic_color | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.5.3" |     version: "1.5.4" | ||||||
|   fake_async: |   fake_async: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -119,7 +168,14 @@ packages: | |||||||
|       name: file |       name: file | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.1.2" |     version: "6.1.4" | ||||||
|  |   file_picker: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: file_picker | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "5.1.0" | ||||||
|   flutter: |   flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -152,21 +208,28 @@ packages: | |||||||
|       name: flutter_local_notifications |       name: flutter_local_notifications | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "9.7.0" |     version: "10.0.0" | ||||||
|   flutter_local_notifications_linux: |   flutter_local_notifications_linux: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: flutter_local_notifications_linux |       name: flutter_local_notifications_linux | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.5.1" |     version: "1.0.0" | ||||||
|   flutter_local_notifications_platform_interface: |   flutter_local_notifications_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: flutter_local_notifications_platform_interface |       name: flutter_local_notifications_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     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: |   flutter_test: | ||||||
|     dependency: "direct dev" |     dependency: "direct dev" | ||||||
|     description: flutter |     description: flutter | ||||||
| @@ -177,6 +240,13 @@ packages: | |||||||
|     description: flutter |     description: flutter | ||||||
|     source: sdk |     source: sdk | ||||||
|     version: "0.0.0" |     version: "0.0.0" | ||||||
|  |   fluttertoast: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: fluttertoast | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "8.0.9" | ||||||
|   html: |   html: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -281,7 +351,7 @@ packages: | |||||||
|       name: path_provider_android |       name: path_provider_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.17" |     version: "2.0.20" | ||||||
|   path_provider_ios: |   path_provider_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -316,7 +386,42 @@ packages: | |||||||
|       name: path_provider_windows |       name: path_provider_windows | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|  |   permission_handler: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: permission_handler | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "10.0.0" | ||||||
|  |   permission_handler_android: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_android | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "10.0.0" | ||||||
|  |   permission_handler_apple: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_apple | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "9.0.4" | ||||||
|  |   permission_handler_platform_interface: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_platform_interface | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "3.7.0" | ||||||
|  |   permission_handler_windows: | ||||||
|  |     dependency: transitive | ||||||
|  |     description: | ||||||
|  |       name: permission_handler_windows | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.1.0" | ||||||
|   petitparser: |   petitparser: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -337,7 +442,7 @@ packages: | |||||||
|       name: plugin_platform_interface |       name: plugin_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|   process: |   process: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -365,7 +470,7 @@ packages: | |||||||
|       name: shared_preferences_android |       name: shared_preferences_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.12" |     version: "2.0.13" | ||||||
|   shared_preferences_ios: |   shared_preferences_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -393,7 +498,7 @@ packages: | |||||||
|       name: shared_preferences_platform_interface |       name: shared_preferences_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.0.0" |     version: "2.1.0" | ||||||
|   shared_preferences_web: |   shared_preferences_web: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -454,7 +559,7 @@ packages: | |||||||
|       name: test_api |       name: test_api | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.4.12" |     version: "0.4.14" | ||||||
|   timezone: |   timezone: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -482,7 +587,7 @@ packages: | |||||||
|       name: url_launcher_android |       name: url_launcher_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "6.0.17" |     version: "6.0.19" | ||||||
|   url_launcher_ios: |   url_launcher_ios: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -531,7 +636,7 @@ packages: | |||||||
|       name: vector_math |       name: vector_math | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.1.2" |     version: "2.1.3" | ||||||
|   webview_flutter: |   webview_flutter: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -545,28 +650,28 @@ packages: | |||||||
|       name: webview_flutter_android |       name: webview_flutter_android | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.5" |     version: "2.10.1" | ||||||
|   webview_flutter_platform_interface: |   webview_flutter_platform_interface: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_platform_interface |       name: webview_flutter_platform_interface | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.9.1" |     version: "1.9.3" | ||||||
|   webview_flutter_wkwebview: |   webview_flutter_wkwebview: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: webview_flutter_wkwebview |       name: webview_flutter_wkwebview | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.9.3" |     version: "2.9.4" | ||||||
|   win32: |   win32: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: win32 |       name: win32 | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "2.7.0" |     version: "3.0.0" | ||||||
|   workmanager: |   workmanager: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
| @@ -580,7 +685,7 @@ packages: | |||||||
|       name: xdg_directories |       name: xdg_directories | ||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "0.2.0+1" |     version: "0.2.0+2" | ||||||
|   xml: |   xml: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -597,4 +702,4 @@ packages: | |||||||
|     version: "3.1.1" |     version: "3.1.1" | ||||||
| sdks: | sdks: | ||||||
|   dart: ">=2.19.0-79.0.dev <3.0.0" |   dart: ">=2.19.0-79.0.dev <3.0.0" | ||||||
|   flutter: ">=3.1.0-0.0.pre.1036" |   flutter: ">=3.3.0" | ||||||
|   | |||||||
							
								
								
									
										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 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||||
| # In Windows, build-name is used as the major, minor, and patch parts | # In Windows, build-name is used as the major, minor, and patch parts | ||||||
| # of the product and file versions while build-number is used as the build suffix. | # of the product and file versions while build-number is used as the build suffix. | ||||||
| version: 0.1.0+1 | version: 0.2.2+13 # When changing this, update the tag in main() accordingly | ||||||
|  |  | ||||||
| environment: | environment: | ||||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' |   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||||
| @@ -35,19 +35,24 @@ dependencies: | |||||||
|  |  | ||||||
|   # The following adds the Cupertino Icons font to your application. |   # The following adds the Cupertino Icons font to your application. | ||||||
|   # Use with the CupertinoIcons class for iOS style icons. |   # Use with the CupertinoIcons class for iOS style icons. | ||||||
|   cupertino_icons: ^1.0.2 |   cupertino_icons: ^1.0.5 | ||||||
|   path_provider: ^2.0.11 |   path_provider: ^2.0.11 | ||||||
|   flutter_fgbg: ^0.2.0 # Try removing reliance on this |   flutter_fgbg: ^0.2.0 # Try removing reliance on this | ||||||
|   flutter_local_notifications: ^9.7.0 |   flutter_local_notifications: ^10.0.0 | ||||||
|   provider: ^6.0.3 |   provider: ^6.0.3 | ||||||
|   http: ^0.13.5 |   http: ^0.13.5 | ||||||
|   webview_flutter: ^3.0.4 |   webview_flutter: ^3.0.4 | ||||||
|   workmanager: ^0.5.0 |   workmanager: ^0.5.0 | ||||||
|   dynamic_color: ^1.5.3 |   dynamic_color: ^1.5.4 | ||||||
|   install_plugin_v2: ^1.0.0 # Try replacing this |   install_plugin_v2: ^1.0.0 # Try replacing this | ||||||
|   html: ^0.15.0 |   html: ^0.15.0 | ||||||
|   shared_preferences: ^2.0.15 |   shared_preferences: ^2.0.15 | ||||||
|   url_launcher: ^6.1.5 |   url_launcher: ^6.1.5 | ||||||
|  |   permission_handler: ^10.0.0 | ||||||
|  |   fluttertoast: ^8.0.9 | ||||||
|  |   device_info_plus: ^4.1.2 | ||||||
|  |   file_picker: ^5.1.0 | ||||||
|  |   animations: ^2.0.4 | ||||||
|  |  | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
| @@ -60,13 +65,13 @@ dev_dependencies: | |||||||
|   # activated in the `analysis_options.yaml` file located at the root of your |   # activated in the `analysis_options.yaml` file located at the root of your | ||||||
|   # package. See that file for information about deactivating specific lint |   # package. See that file for information about deactivating specific lint | ||||||
|   # rules and activating additional ones. |   # rules and activating additional ones. | ||||||
|   flutter_lints: ^2.0.0 |   flutter_lints: ^2.0.1 | ||||||
|  |  | ||||||
| flutter_icons: | flutter_icons: | ||||||
|   android: true |   android: true | ||||||
|   image_path: "assets/icon.png" |   image_path: "assets/graphics/icon.png" | ||||||
|   adaptive_icon_background: "#FFFFFF" |   adaptive_icon_background: "#FFFFFF" | ||||||
|   adaptive_icon_foreground: "assets/icon.png" |   adaptive_icon_foreground: "assets/graphics/icon.png" | ||||||
|  |  | ||||||
| # For information on the generic Dart part of this file, see the | # For information on the generic Dart part of this file, see the | ||||||
| # following page: https://dart.dev/tools/pub/pubspec | # following page: https://dart.dev/tools/pub/pubspec | ||||||
|   | |||||||