Compare commits

...

33 Commits

Author SHA1 Message Date
9e21f2d6e6 Updated version 2022-09-17 02:16:11 -04:00
6f11f850e0 Import now uses file picker 2022-09-17 02:12:17 -04:00
5e96b91029 Updated version 2022-09-17 01:43:54 -04:00
5fc79af960 Added App sorting 2022-09-17 01:41:38 -04:00
05f5590e7d Updated modules, removed unneeded imports 2022-09-17 01:10:34 -04:00
50f8caeb47 Added "Already Installed" button 2022-09-17 00:59:15 -04:00
f966a9e626 Finished import/export changes 2022-09-17 00:39:56 -04:00
02a5749ba7 Removed redundant code 2022-09-17 00:09:46 -04:00
4ccf7cbc92 Added GitHub starred import (+ general import/export changes) 2022-09-16 23:52:58 -04:00
ab4efd85ce Added IzzyOnDroid App Source
+ Bugfix for third party APK URL support
+ F-Droid apps have F-Droid as Author now
2022-09-16 20:24:47 -04:00
42bba0f64c Added option to disable background update checking 2022-09-16 19:53:57 -04:00
294327bde4 FIXED GITHUB ISSUE 2022-09-13 21:42:06 -04:00
52b97662c6 Updated plugins, incremented app version, ui tweaks 2022-09-03 17:31:19 -04:00
f63da4b538 Added option to not show App webpage + wording tweak 2022-09-03 17:06:46 -04:00
c30c692d87 Added external APK support (GitLab only for now) 2022-09-03 16:12:25 -04:00
d643d5a474 Fixed invisible nav buttons on pre Android Q 2022-09-03 15:30:00 -04:00
f8101a5d9f Updated version 2022-08-28 19:26:13 -04:00
c2a7e4a0d2 Bugfix - update checking on app load was broken 2022-08-28 18:17:03 -04:00
285da7545b Slight offset on ic_notification 2022-08-27 23:56:44 -04:00
a5230acc11 Added store icon 2022-08-27 23:43:54 -04:00
53019818a6 Rearranged some folders, added graphics 2022-08-27 23:01:29 -04:00
1a04d39144 Updated README.md with new App Sources 2022-08-27 22:34:56 -04:00
96c1ed612d Added F-Droid, Mullvad. Bug fixes. 2022-08-27 22:22:59 -04:00
4d75a6a361 Tiny UI tweak 2022-08-27 19:27:16 -04:00
30075add1c Fixed APKPicker radiobutton + preferred apk index saved 2022-08-27 19:17:29 -04:00
52b4e1fb96 bugfix 2022-08-27 18:05:49 -04:00
f9044e20f1 Refactors to source_provider - less redundancy 2022-08-27 18:03:45 -04:00
7e5affe1b8 Added Signal.org, fixed bugs, UX tweaks, readme update 2022-08-27 17:47:08 -04:00
5bdab1b1e4 Remove prev. error notif if any when bg update checking 2022-08-27 16:41:01 -04:00
c14c4d2f14 Back button switches to apps + more haptics 2022-08-27 16:37:27 -04:00
5e785ae1d5 haptic feedback, listed sources 2022-08-27 16:25:45 -04:00
6c076751ab Fixed APK picker + UX tweak 2022-08-27 15:43:29 -04:00
4253203dca Tiny UI/UX tweaks 2022-08-27 04:01:25 -04:00
26 changed files with 1352 additions and 356 deletions

View File

@ -1,21 +1,25 @@
# ![](./android/app/src/main/res/drawable/ic_notification.png) Obtainium # ![Obtainium Icon](./android/app/src/main/res/drawable/ic_notification.png) 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
- GitLab
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)
Currently supported App sources:
- [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/)
- [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/)
## Limitations ## Limitations
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected. - App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
- 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 ## Screenshots
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> | | <img src="./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="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> | | <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" /> |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/graphics/banner.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
assets/graphics/icon.psd Executable file

Binary file not shown.

BIN
assets/graphics/obtainium.psd Executable file

Binary file not shown.

BIN
assets/graphics/store-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 263 KiB

View File

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

View File

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class GeneratedFormItem {
late String message;
late bool required;
late int lines;
GeneratedFormItem(this.message, this.required, this.lines);
}
class GeneratedFormModal extends StatefulWidget {
const GeneratedFormModal(
{super.key, required this.title, required this.items});
final String title;
final List<GeneratedFormItem> items;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
final _formKey = GlobalKey<FormState>();
final urlInputController = TextEditingController();
@override
Widget build(BuildContext context) {
final formInputs = widget.items.map((e) {
final controller = TextEditingController();
return [
controller,
TextFormField(
decoration: InputDecoration(helperText: e.message),
controller: controller,
minLines: e.lines <= 1 ? null : e.lines,
maxLines: e.lines <= 1 ? 1 : e.lines,
validator: e.required
? (value) {
if (value == null || value.isEmpty) {
return '${e.message} (required)';
}
return null;
}
: null,
)
];
}).toList();
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [...formInputs.map((e) => e[1] as Widget)],
)),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
if (_formKey.currentState?.validate() == true) {
HapticFeedback.heavyImpact();
Navigator.of(context).pop(formInputs
.map((e) => (e[0] as TextEditingController).value.text)
.toList());
}
},
child: const Text('Continue'))
],
);
}
}
// TODO: Add support for larger textarea so this can be used for text/json imports

View File

@ -9,18 +9,21 @@ 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';
const String currentReleaseTag = const String currentReleaseTag =
'v0.1.3-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v0.2.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point') @pragma('vm:entry-point')
void bgTaskCallback() { void bgTaskCallback() {
// Background update checking process // Background update checking process
Workmanager().executeTask((task, taskName) async { Workmanager().executeTask((task, taskName) async {
var appsProvider = AppsProvider(bg: true);
var notificationsProvider = NotificationsProvider(); var notificationsProvider = NotificationsProvider();
await notificationsProvider.notify(checkingUpdatesNotification); await notificationsProvider.notify(checkingUpdatesNotification);
try { try {
var appsProvider = AppsProvider();
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps(); await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates(); List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) { if (updates.isNotEmpty) {
@ -41,16 +44,22 @@ void bgTaskCallback() {
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle( if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), SystemChrome.setSystemUIOverlayStyle(
); const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); );
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
Workmanager().initialize( Workmanager().initialize(
bgTaskCallback, bgTaskCallback,
); );
runApp(MultiProvider( runApp(MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (context) => AppsProvider()), ChangeNotifierProvider(
create: (context) => AppsProvider(
shouldLoadApps: true,
shouldCheckUpdatesAfterLoad: true,
shouldDeleteAPKs: true)),
ChangeNotifierProvider(create: (context) => SettingsProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()),
Provider(create: (context) => NotificationsProvider()) Provider(create: (context) => NotificationsProvider())
], ],
@ -69,30 +78,31 @@ class MyApp extends StatelessWidget {
AppsProvider appsProvider = context.read<AppsProvider>(); AppsProvider appsProvider = context.read<AppsProvider>();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings().then((value) { settingsProvider.initializeSettings();
// Delete past downloads and check for updates every time the app is launched
// Only runs once as the settings are only initialized once (so not on every build)
appsProvider.deleteSavedAPKs();
appsProvider.checkUpdates();
});
} else { } else {
// Register the background update task according to the user's setting // Register the background update task according to the user's setting
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check', if (settingsProvider.updateInterval > 0) {
frequency: Duration(minutes: settingsProvider.updateInterval), Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
initialDelay: Duration(minutes: settingsProvider.updateInterval), frequency: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected), initialDelay: Duration(minutes: settingsProvider.updateInterval),
existingWorkPolicy: ExistingWorkPolicy.replace); 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) {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request(); Permission.notification.request();
appsProvider.saveApp(App( appsProvider.saveApp(App(
'imranr98_obtainium_github', 'imranr98_obtainium_${GitHub().host}',
'https://github.com/ImranR98/Obtainium', 'https://github.com/ImranR98/Obtainium',
'ImranR98', 'ImranR98',
'Obtainium', 'Obtainium',
currentReleaseTag, currentReleaseTag,
currentReleaseTag, [])); currentReleaseTag,
[],
0));
} }
} }
@ -123,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());
}); });
} }

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_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});
@ -19,77 +21,114 @@ class _AddAppPageState extends State<AddAppPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
return Center( return Center(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
const Spacer(), children: [
Padding( Container(),
padding: const EdgeInsets.symmetric(horizontal: 16.0), Padding(
child: TextFormField( padding: const EdgeInsets.all(16),
decoration: const InputDecoration( child: Column(
hintText: 'https://github.com/Author/Project', crossAxisAlignment: CrossAxisAlignment.stretch,
helperText: 'Enter the App source URL'), children: [
controller: urlInputController, TextFormField(
validator: (value) { decoration: const InputDecoration(
if (value == null || hintText: 'https://github.com/Author/Project',
value.isEmpty || helperText: 'Enter the App source URL'),
Uri.tryParse(value) == null) { controller: urlInputController,
return 'Please enter a supported source URL'; validator: (value) {
} if (value == null ||
return null; value.isEmpty ||
}, Uri.tryParse(value) == null) {
)), return 'Please enter a supported source URL';
Padding( }
padding: return null;
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), },
child: ElevatedButton( ),
onPressed: gettingAppInfo Padding(
? null padding: const EdgeInsets.symmetric(vertical: 16.0),
: () { child: ElevatedButton(
if (_formKey.currentState!.validate()) { onPressed: gettingAppInfo
setState(() { ? null
gettingAppInfo = true; : () {
}); HapticFeedback.mediumImpact();
sourceProvider() if (_formKey.currentState!.validate()) {
.getApp(urlInputController.value.text) setState(() {
.then((app) { gettingAppInfo = true;
var appsProvider = context.read<AppsProvider>(); });
var settingsProvider = sourceProvider
context.read<SettingsProvider>(); .getApp(urlInputController.value.text)
if (appsProvider.apps.containsKey(app.id)) { .then((app) {
throw 'App already added'; var appsProvider =
} context.read<AppsProvider>();
settingsProvider.getInstallPermission().then((_) { var settingsProvider =
appsProvider.saveApp(app).then((_) { context.read<SettingsProvider>();
urlInputController.clear(); if (appsProvider.apps.containsKey(app.id)) {
Navigator.push( throw 'App already added';
context, }
MaterialPageRoute( settingsProvider
builder: (context) => .getInstallPermission()
AppPage(appId: app.id))); .then((_) {
}); appsProvider.saveApp(app).then((_) {
}); urlInputController.clear();
}).catchError((e) { Navigator.push(
ScaffoldMessenger.of(context).showSnackBar( context,
SnackBar(content: Text(e.toString())), MaterialPageRoute(
); builder: (context) =>
}).whenComplete(() { AppPage(appId: app.id)));
setState(() { });
gettingAppInfo = false; });
}); }).catchError((e) {
}); ScaffoldMessenger.of(context).showSnackBar(
} SnackBar(content: Text(e.toString())),
}, );
child: const Text('Add'), }).whenComplete(() {
), setState(() {
), gettingAppInfo = false;
const Spacer(), });
if (gettingAppInfo) const LinearProgressIndicator(), });
], }
), },
)); child: const Text('Add'),
),
),
],
),
),
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
const Text(
'Supported Sources:',
// style: TextStyle(fontWeight: FontWeight.bold),
// style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(
height: 8,
),
...sourceProvider
.getSourceHosts()
.map((e) => GestureDetector(
onTap: () {
launchUrlString('https://$e',
mode: LaunchMode.externalApplication);
},
child: Text(
e,
style: const TextStyle(
decoration: TextDecoration.underline,
fontStyle: FontStyle.italic),
)))
.toList()
]),
if (gettingAppInfo)
const LinearProgressIndicator()
else
Container(),
],
)),
);
} }
} }

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -16,6 +19,7 @@ class _AppPageState extends State<AppPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
AppInMemory? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
if (app?.app.installedVersion != null) { if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.app.id); appsProvider.getUpdate(app!.app.id);
@ -24,10 +28,58 @@ class _AppPageState extends State<AppPage> {
appBar: AppBar( appBar: AppBar(
title: Text('${app?.app.author}/${app?.app.name}'), title: Text('${app?.app.author}/${app?.app.name}'),
), ),
body: WebView( body: settingsProvider.showAppWebpage
initialUrl: app?.app.url, ? WebView(
javascriptMode: JavascriptMode.unrestricted, 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),
@ -39,17 +91,59 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion == null)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text(
'App Already Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
updatedApp.latestVersion;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Installed'))
],
);
});
},
tooltip: 'Mark as Installed',
icon: const Icon(Icons.done)),
if (app?.app.installedVersion == null)
const SizedBox(width: 16.0),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
appsProvider appsProvider
.checkAppObjectForUpdate( .checkAppObjectForUpdate(
app!.app)) && app!.app)) &&
app?.downloadProgress == null !appsProvider.areDownloadsRunning()
? () { ? () {
HapticFeedback.heavyImpact();
appsProvider appsProvider
.downloadAndInstallLatestApp( .downloadAndInstallLatestApp(
[app!.app.id], context); [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
@ -60,6 +154,7 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () {
HapticFeedback.lightImpact();
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
@ -70,6 +165,7 @@ class _AppPageState extends State<AppPage> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact();
appsProvider appsProvider
.removeApp(app!.app.id) .removeApp(app!.app.id)
.then((_) { .then((_) {
@ -82,6 +178,7 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove')), child: const Text('Remove')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Cancel')) child: const Text('Cancel'))
@ -90,8 +187,10 @@ class _AppPageState extends State<AppPage> {
}); });
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: Theme.of(context).errorColor, foregroundColor:
surfaceTintColor: Theme.of(context).errorColor), Theme.of(context).colorScheme.error,
surfaceTintColor:
Theme.of(context).colorScheme.error),
child: const Text('Remove'), child: const Text('Remove'),
), ),
])), ])),

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -15,21 +16,33 @@ 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>();
var settingsProvider = context.watch<SettingsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates(); 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 Scaffold( return Scaffold(
floatingActionButton: existingUpdateAppIds.isEmpty floatingActionButton: existingUpdateAppIds.isEmpty
? null ? null
: ElevatedButton.icon( : ElevatedButton.icon(
onPressed: appsProvider.apps.values onPressed: appsProvider.areDownloadsRunning()
.where((element) => element.downloadProgress != null)
.isNotEmpty
? null ? null
: () { : () {
context HapticFeedback.heavyImpact();
.read<SettingsProvider>() settingsProvider.getInstallPermission().then((_) {
.getInstallPermission()
.then((_) {
appsProvider.downloadAndInstallLatestApp( appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context); existingUpdateAppIds, context);
}); });
@ -42,12 +55,15 @@ class _AppsPageState extends State<AppsPage> {
: appsProvider.apps.isEmpty : appsProvider.apps.isEmpty
? Text( ? Text(
'No Apps', 'No Apps',
style: Theme.of(context).textTheme.headline4, style: Theme.of(context).textTheme.headlineMedium,
) )
: RefreshIndicator( : RefreshIndicator(
onRefresh: appsProvider.checkUpdates, onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates();
},
child: ListView( child: ListView(
children: appsProvider.apps.values children: sortedApps
.map( .map(
(e) => ListTile( (e) => ListTile(
title: Text('${e.app.author}/${e.app.name}'), title: Text('${e.app.author}/${e.app.name}'),
@ -55,7 +71,7 @@ class _AppsPageState extends State<AppsPage> {
e.app.installedVersion ?? 'Not Installed'), e.app.installedVersion ?? 'Not Installed'),
trailing: e.downloadProgress != null trailing: e.downloadProgress != null
? Text( ? Text(
'Downloading - ${e.downloadProgress!.toInt()}%') 'Downloading - ${e.downloadProgress?.toInt()}%')
: (e.app.installedVersion != null && : (e.app.installedVersion != null &&
e.app.installedVersion != e.app.installedVersion !=
e.app.latestVersion e.app.latestVersion

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/apps.dart'; import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart'; import 'package:obtainium/pages/settings.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
@ -11,31 +13,58 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
int selectedIndex = 1; List<int> selectedIndexHistory = [];
List<Widget> pages = [ List<Widget> pages = [
const SettingsPage(),
const AppsPage(), const AppsPage(),
const AddAppPage() const AddAppPage(),
const ImportExportPage(),
const SettingsPage()
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return WillPopScope(
appBar: AppBar(title: const Text('Obtainium')), child: Scaffold(
body: pages.elementAt(selectedIndex), appBar: AppBar(title: const Text('Obtainium')),
bottomNavigationBar: NavigationBar( body: pages.elementAt(
destinations: const [ selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'), bottomNavigationBar: NavigationBar(
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), destinations: const [
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
], NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
onDestinationSelected: (int index) { NavigationDestination(
setState(() { icon: Icon(Icons.import_export), label: 'Import/Export'),
selectedIndex = index; NavigationDestination(
}); icon: Icon(Icons.settings), label: 'Settings'),
}, ],
selectedIndex: selectedIndex, onDestinationSelected: (int index) {
), HapticFeedback.lightImpact();
); setState(() {
if (index == 0) {
selectedIndexHistory.clear();
} else if (selectedIndexHistory.isEmpty ||
(selectedIndexHistory.isNotEmpty &&
selectedIndexHistory.last != index)) {
int existingInd = selectedIndexHistory.indexOf(index);
if (existingInd >= 0) {
selectedIndexHistory.removeAt(existingInd);
}
selectedIndexHistory.add(index);
}
});
},
selectedIndex:
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
),
),
onWillPop: () async {
if (selectedIndexHistory.isNotEmpty) {
setState(() {
selectedIndexHistory.removeLast();
});
return false;
}
return true;
});
} }
} }

View File

@ -0,0 +1,288 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/components/generated_form_modal.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key});
@override
State<ImportExportPage> createState() => _ImportExportPageState();
}
class _ImportExportPageState extends State<ImportExportPage> {
bool importInProgress = false;
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>();
Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls);
List<App> apps = results[0];
Map<String, dynamic> errorsMap = results[1];
for (var app in apps) {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApp(app);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
onPressed: appsProvider.apps.isEmpty || importInProgress
? null
: () {
HapticFeedback.lightImpact();
appsProvider.exportApps().then((String path) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Exported to $path')),
);
});
},
child: const Text('Obtainium Export')),
const SizedBox(
height: 8,
),
ElevatedButton(
onPressed: importInProgress
? null
: () {
HapticFeedback.lightImpact();
FilePicker.platform.pickFiles().then((result) {
setState(() {
importInProgress = true;
});
if (result != null) {
String data = File(result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw 'Invalid input';
}
appsProvider.importApps(data).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$value App${value == 1 ? '' : 's'} Imported')),
);
});
} else {
// User canceled the picker
}
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
},
child: const Text('Obtainium Import')),
if (importInProgress)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import from URL List',
items: [
GeneratedFormItem('App URL List', true, 7)
],
);
}).then((values) {
if (values != null) {
var urls = (values[0] as String).split('\n');
setState(() {
importInProgress = true;
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ImportErrorDialog(
urlsLength: urls.length,
errors: errors);
});
}
}).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}
});
},
child: const Text('Import from URL List')),
...sourceProvider.massSources
.map((source) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
TextButton(
onPressed: importInProgress
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Import ${source.name}',
items: source.requiredArgs
.map((e) =>
GeneratedFormItem(
e, true, 1))
.toList());
}).then((values) {
if (values != null) {
source.getUrls(values).then((urls) {
setState(() {
importInProgress = true;
});
addApps(urls).then((errors) {
if (errors.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Imported ${urls.length} Apps')),
);
} else {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return ImportErrorDialog(
urlsLength:
urls.length,
errors: errors);
});
}
}).whenComplete(() {
setState(() {
importInProgress = false;
});
});
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(e.toString())),
);
});
}
});
},
child: Text('Import ${source.name}'))
]))
.toList()
],
));
}
}
class ImportErrorDialog extends StatefulWidget {
const ImportErrorDialog(
{super.key, required this.urlsLength, required this.errors});
final int urlsLength;
final List<List<String>> errors;
@override
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
}
class _ImportErrorDialogState extends State<ImportErrorDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Import Errors'),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'The following URLs had errors:',
style: Theme.of(context).textTheme.bodyLarge,
),
...widget.errors.map((e) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(
height: 16,
),
Text(e[0]),
Text(
e[1],
style: const TextStyle(fontStyle: FontStyle.italic),
)
]);
}).toList()
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Okay'))
],
);
}
}

View File

@ -1,7 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/providers/settings_provider.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';
@ -16,7 +14,6 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppsProvider appsProvider = context.read<AppsProvider>();
SettingsProvider settingsProvider = context.watch<SettingsProvider>(); SettingsProvider settingsProvider = context.watch<SettingsProvider>();
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
@ -102,6 +99,10 @@ class _SettingsPageState extends State<SettingsPage> {
value: 1440, value: 1440,
child: Text('1 Day'), child: Text('1 Day'),
), ),
DropdownMenuItem(
value: 0,
child: Text('Never - Manual Only'),
),
], ],
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
@ -109,105 +110,65 @@ class _SettingsPageState extends State<SettingsPage> {
} }
}), }),
const SizedBox( const SizedBox(
height: 32, height: 16,
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'App Sort By'),
value: settingsProvider.sortColumn,
items: const [
DropdownMenuItem(
value: SortColumnSettings.authorName,
child: Text('Author/Name'),
),
DropdownMenuItem(
value: SortColumnSettings.nameAuthor,
child: Text('Name/Author'),
),
DropdownMenuItem(
value: SortColumnSettings.added,
child: Text('As Added'),
)
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortColumn = value;
}
}),
const SizedBox(
height: 16,
),
DropdownButtonFormField(
decoration:
const InputDecoration(labelText: 'App Sort Order'),
value: settingsProvider.sortOrder,
items: const [
DropdownMenuItem(
value: SortOrderSettings.ascending,
child: Text('Ascending'),
),
DropdownMenuItem(
value: SortOrderSettings.descending,
child: Text('Descending'),
),
],
onChanged: (value) {
if (value != null) {
settingsProvider.sortOrder = value;
}
}),
const SizedBox(
height: 16,
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
ElevatedButton( const Text('Show Source Webpage in App View'),
onPressed: appsProvider.apps.isEmpty Switch(
? null value: settingsProvider.showAppWebpage,
: () { onChanged: (value) {
appsProvider.exportApps().then((String path) { settingsProvider.showAppWebpage = value;
ScaffoldMessenger.of(context).showSnackBar( })
SnackBar(
content: Text('Exported to $path')),
);
});
},
child: const Text('Export Apps')),
ElevatedButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
final formKey = GlobalKey<FormState>();
final jsonInputController =
TextEditingController();
return AlertDialog(
scrollable: true,
title: const Text('Import Apps'),
content: Column(children: [
const Text(
'Copy the contents of the Obtainium export file and paste them into the field below:'),
Form(
key: formKey,
child: TextFormField(
minLines: 7,
maxLines: 7,
decoration: const InputDecoration(
helperText:
'Obtainium export data'),
controller: jsonInputController,
validator: (value) {
if (value == null ||
value.isEmpty) {
return 'Please enter your Obtainium export data';
}
bool isJSON = true;
try {
jsonDecode(value);
} catch (e) {
isJSON = false;
}
if (!isJSON) {
return 'Invalid input';
}
return null;
},
),
)
]),
actions: [
TextButton(
onPressed: () {
if (formKey.currentState!
.validate()) {
appsProvider
.importApps(
jsonInputController
.value.text)
.then((value) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'$value Apps Imported')),
);
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content:
Text(e.toString())),
);
}).whenComplete(() {
Navigator.of(context).pop();
});
}
},
child: const Text('Import')),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'))
],
);
});
},
child: const Text('Import Apps'))
], ],
), ),
const Spacer(), const Spacer(),
@ -223,13 +184,14 @@ class _SettingsPageState extends State<SettingsPage> {
}), }),
), ),
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
launchUrlString(settingsProvider.sourceUrl, launchUrlString(settingsProvider.sourceUrl,
mode: LaunchMode.externalApplication); mode: LaunchMode.externalApplication);
}, },
icon: const Icon(Icons.code), icon: const Icon(Icons.code),
label: Text( label: Text(
'Source', 'Source',
style: Theme.of(context).textTheme.caption, style: Theme.of(context).textTheme.bodySmall,
), ),
) )
], ],

View File

@ -6,6 +6,7 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/providers/notifications_provider.dart'; import 'package:obtainium/providers/notifications_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -38,14 +39,26 @@ class AppsProvider with ChangeNotifier {
late Stream<FGBGType> foregroundStream; late Stream<FGBGType> foregroundStream;
late StreamSubscription<FGBGType> foregroundSubscription; late StreamSubscription<FGBGType> foregroundSubscription;
AppsProvider({bool bg = false}) { AppsProvider(
{bool shouldLoadApps = false,
bool shouldCheckUpdatesAfterLoad = false,
bool shouldDeleteAPKs = false}) {
// Subscribe to changes in the app foreground status // Subscribe to changes in the app foreground status
foregroundStream = FGBGEvents.stream.asBroadcastStream(); foregroundStream = FGBGEvents.stream.asBroadcastStream();
foregroundSubscription = foregroundStream.listen((event) async { foregroundSubscription = foregroundStream.listen((event) async {
isForeground = event == FGBGType.foreground; isForeground = event == FGBGType.foreground;
if (isForeground) await loadApps(); if (isForeground) await loadApps();
}); });
loadApps(); if (shouldDeleteAPKs) {
deleteSavedAPKs();
}
if (shouldLoadApps) {
loadApps().then((_) {
if (shouldCheckUpdatesAfterLoad) {
checkUpdates();
}
});
}
} }
Future<ApkFile> downloadApp(String apkUrl, String appId) async { Future<ApkFile> downloadApp(String apkUrl, String appId) async {
@ -79,10 +92,14 @@ class AppsProvider with ChangeNotifier {
return ApkFile(appId, downloadFile); 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 // 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 // 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 // Returns upon successful download, regardless of installation result
Future<void> downloadAndInstallLatestApp( Future<bool> downloadAndInstallLatestApp(
List<String> appIds, BuildContext context) async { List<String> appIds, BuildContext context) async {
NotificationsProvider notificationsProvider = NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>(); context.read<NotificationsProvider>();
@ -91,37 +108,36 @@ class AppsProvider with ChangeNotifier {
if (apps[id] == null) { if (apps[id] == null) {
throw 'App not found'; throw 'App not found';
} }
String apkUrl = apps[id]!.app.apkUrls.last; // 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) { if (apps[id]!.app.apkUrls.length > 1) {
await showDialog( apkUrl = await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return APKPicker(app: apps[id]!.app, initVal: apkUrl);
scrollable: true,
title: const Text('Pick an APK'),
content: Column(children: [
Text(
'${apps[id]!.app.name} has more than one package - pick one.'),
...apps[id]!.app.apkUrls.map((u) => ListTile(
title: Text(Uri.parse(u).pathSegments.last),
leading: Radio<String>(
value: u,
groupValue: apkUrl,
onChanged: (String? val) {
apkUrl = val!;
})))
]),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Continue'))
],
);
}); });
} }
appsToInstall.putIfAbsent(id, () => apkUrl); // 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 List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
@ -131,6 +147,7 @@ class AppsProvider with ChangeNotifier {
await notificationsProvider.notify(completeInstallationNotification, await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true); cancelExisting: true);
await FGBGEvents.stream.first == FGBGType.foreground; 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 // 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: // 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 // https://github.com/flutter/flutter/issues/13937
@ -144,6 +161,8 @@ class AppsProvider with ChangeNotifier {
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion; apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
await saveApp(apps[f.appId]!.app); await saveApp(apps[f.appId]!.app);
} }
return downloadedFiles.isNotEmpty;
} }
Future<Directory> getAppsDir() async { Future<Directory> getAppsDir() async {
@ -209,9 +228,12 @@ class AppsProvider with ChangeNotifier {
Future<App?> getUpdate(String appId) async { Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]!.app; App? currentApp = apps[appId]!.app;
App newApp = await sourceProvider().getApp(currentApp.url); App newApp = await SourceProvider().getApp(currentApp.url);
if (newApp.latestVersion != currentApp.latestVersion) { if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion; newApp.installedVersion = currentApp.installedVersion;
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
await saveApp(newApp); await saveApp(newApp);
return newApp; return newApp;
} }
@ -281,3 +303,90 @@ class AppsProvider with ChangeNotifier {
super.dispose(); super.dispose();
} }
} }
class APKPicker extends StatefulWidget {
const APKPicker({super.key, required this.app, this.initVal});
final App app;
final String? initVal;
@override
State<APKPicker> createState() => _APKPickerState();
}
class _APKPickerState extends State<APKPicker> {
String? apkUrl;
@override
Widget build(BuildContext context) {
apkUrl ??= widget.initVal;
return AlertDialog(
scrollable: true,
title: const Text('Pick an APK'),
content: Column(children: [
Text('${widget.app.name} has more than one package:'),
const SizedBox(height: 16),
...widget.app.apkUrls.map((u) => RadioListTile<String>(
title: Text(Uri.parse(u).pathSegments.last),
value: u,
groupValue: apkUrl,
onChanged: (String? val) {
setState(() {
apkUrl = val;
});
}))
]),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
Navigator.of(context).pop(apkUrl);
},
child: const Text('Continue'))
],
);
}
}
class APKOriginWarningDialog extends StatefulWidget {
const APKOriginWarningDialog(
{super.key, required this.sourceUrl, required this.apkUrl});
final String sourceUrl;
final String apkUrl;
@override
State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
}
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: const Text('Warning'),
content: Text(
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
actions: [
TextButton(
onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
Navigator.of(context).pop(true);
},
child: const Text('Continue'))
],
);
}
}

View File

@ -9,6 +9,10 @@ enum ThemeSettings { system, light, dark }
enum ColourSettings { basic, materialYou } enum ColourSettings { basic, materialYou }
enum SortColumnSettings { added, nameAuthor, authorName }
enum SortOrderSettings { ascending, descending }
class SettingsProvider with ChangeNotifier { class SettingsProvider with ChangeNotifier {
SharedPreferences? prefs; SharedPreferences? prefs;
@ -45,7 +49,27 @@ class SettingsProvider with ChangeNotifier {
} }
set updateInterval(int min) { set updateInterval(int min) {
prefs?.setInt('updateInterval', min < 15 ? 15 : 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(); notifyListeners();
} }
@ -69,4 +93,13 @@ class SettingsProvider with ChangeNotifier {
} }
} }
} }
bool get showAppWebpage {
return prefs?.getBool('showAppWebpage') ?? true;
}
set showAppWebpage(bool show) {
prefs?.setBool('showAppWebpage', show);
notifyListeners();
}
} }

View File

@ -29,8 +29,9 @@ class App {
String? installedVersion; String? installedVersion;
late String latestVersion; late String latestVersion;
List<String> apkUrls = []; List<String> apkUrls = [];
late int preferredApkIndex;
App(this.id, this.url, this.author, this.name, this.installedVersion, App(this.id, this.url, this.author, this.name, this.installedVersion,
this.latestVersion, this.apkUrls); this.latestVersion, this.apkUrls, this.preferredApkIndex);
@override @override
String toString() { String toString() {
@ -38,15 +39,19 @@ class App {
} }
factory App.fromJson(Map<String, dynamic> json) => App( factory App.fromJson(Map<String, dynamic> json) => App(
json['id'] as String, json['id'] as String,
json['url'] as String, json['url'] as String,
json['author'] as String, json['author'] as String,
json['name'] as String, json['name'] as String,
json['installedVersion'] == null json['installedVersion'] == null
? null ? null
: json['installedVersion'] as String, : json['installedVersion'] as String,
json['latestVersion'] as String, json['latestVersion'] as String,
List<String>.from(jsonDecode(json['apkUrls']))); List<String>.from(jsonDecode(json['apkUrls'])),
json['preferredApkIndex'] == null
? 0
: json['preferredApkIndex'] as int,
);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@ -56,6 +61,7 @@ class App {
'installedVersion': installedVersion, 'installedVersion': installedVersion,
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrls': jsonEncode(apkUrls), 'apkUrls': jsonEncode(apkUrls),
'preferredApkIndex': preferredApkIndex
}; };
} }
@ -65,6 +71,12 @@ escapeRegEx(String s) {
}); });
} }
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( List<String> getLinksFromParsedHTML(
Document dom, RegExp hrefPattern, String prependToLinks) => Document dom, RegExp hrefPattern, String prependToLinks) =>
dom dom
@ -77,7 +89,7 @@ List<String> getLinksFromParsedHTML(
.toList(); .toList();
abstract class AppSource { abstract class AppSource {
late String sourceId; late String host;
String standardizeURL(String url); String standardizeURL(String url);
Future<APKDetails> getLatestAPKDetails(String standardUrl); Future<APKDetails> getLatestAPKDetails(String standardUrl);
AppNames getAppNames(String standardUrl); AppNames getAppNames(String standardUrl);
@ -85,52 +97,60 @@ abstract class AppSource {
class GitHub implements AppSource { class GitHub implements AppSource {
@override @override
String sourceId = 'github'; late String host = 'github.com';
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw 'Not a valid URL'; throw notValidURL;
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
// The GitHub RSS feed does not contain asset download details, so we use web scraping (avoid API due to rate limits) Response res = await get(Uri.parse(
Response res = await get(Uri.parse('$standardUrl/releases/latest')); 'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl); var releases = jsonDecode(res.body) as List<dynamic>;
var parsedHtml = parse(res.body); // Right now, the latest non-prerelease version is picked
var apkUrlList = getLinksFromParsedHTML( // If none exists, the latest prerelease version is picked
parsedHtml, // In the future, the user could be given a choice
RegExp( var nonPrereleaseReleases =
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$', releases.where((element) => element['prerelease'] != true).toList();
caseSensitive: false), var latestRelease = nonPrereleaseReleases.isNotEmpty
standardUri.origin); ? nonPrereleaseReleases[0]
if (apkUrlList.isEmpty) { : releases.isNotEmpty
throw 'No APK found'; ? releases[0]
: null;
if (latestRelease == null) {
throw couldNotFindReleases;
} }
String getTag(String url) { List<dynamic>? assets = latestRelease['assets'];
List<String> parts = url.split('/'); List<String>? apkUrlList = assets
return parts[parts.length - 2]; ?.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';
} }
String latestTag = getTag(apkUrlList[0]); throw couldNotFindReleases;
String? version = parsedHtml
.querySelector('.octicon-tag')
?.nextElementSibling
?.innerHtml
.trim();
if (version == null) {
throw 'Could not determine latest release version';
}
return APKDetails(version,
apkUrlList.where((element) => getTag(element) == latestTag).toList());
} else {
throw 'Unable to fetch release info';
} }
} }
@ -144,47 +164,53 @@ class GitHub implements AppSource {
class GitLab implements AppSource { class GitLab implements AppSource {
@override @override
String sourceId = 'gitlab'; late String host = 'gitlab.com';
@override @override
String standardizeURL(String url) { String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp(r'^https?://gitlab.com/[^/]*/[^/]*'); RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) { if (match == null) {
throw 'Not a valid URL'; throw notValidURL;
} }
return url.substring(0, match.end); return url.substring(0, match.end);
} }
@override @override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async { Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
// GitLab provides an RSS feed with all the details we need
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
var standardUri = Uri.parse(standardUrl); var standardUri = Uri.parse(standardUrl);
var parsedHtml = parse(res.body); var parsedHtml = parse(res.body);
var entry = parsedHtml.querySelector('entry'); var entry = parsedHtml.querySelector('entry');
var entryContent = var entryContent =
parse(parseFragment(entry!.querySelector('content')!.innerHtml).text); parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
var apkUrlList = getLinksFromParsedHTML( var apkUrlList = [
entryContent, ...getLinksFromParsedHTML(
RegExp( entryContent,
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$', RegExp(
caseSensitive: false), '^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
standardUri.origin); 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) { if (apkUrlList.isEmpty) {
throw 'No APK found'; throw noAPKFound;
} }
var entryId = entry.querySelector('id')?.innerHtml; var entryId = entry?.querySelector('id')?.innerHtml;
var version = var version =
entryId == null ? null : Uri.parse(entryId).pathSegments.last; entryId == null ? null : Uri.parse(entryId).pathSegments.last;
if (version == null) { if (version == null) {
throw 'Could not determine latest release version'; throw couldNotFindLatestVersion;
} }
return APKDetails(version, apkUrlList); return APKDetails(version, apkUrlList);
} else { } else {
throw 'Unable to fetch release info'; throw couldNotFindReleases;
} }
} }
@ -195,15 +221,204 @@ class GitLab implements AppSource {
} }
} }
class sourceProvider { class Signal implements AppSource {
@override
late String host = 'signal.org';
@override
String standardizeURL(String url) {
return 'https://$host';
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res =
await get(Uri.parse('https://updates.$host/android/latest.json'));
if (res.statusCode == 200) {
var json = jsonDecode(res.body);
String? apkUrl = json['url'];
if (apkUrl == null) {
throw noAPKFound;
}
String? version = json['versionName'];
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
}
class FDroid implements AppSource {
@override
late String host = 'f-droid.org';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var latestReleaseDiv =
parse(res.body).querySelector('#latest.package-version');
var apkUrl = latestReleaseDiv
?.querySelector('.package-version-download a')
?.attributes['href'];
if (apkUrl == null) {
throw noAPKFound;
}
var version = latestReleaseDiv
?.querySelector('.package-version-header b')
?.innerHtml
.split(' ')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [apkUrl]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
}
}
class Mullvad implements AppSource {
@override
late String host = 'mullvad.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
if (res.statusCode == 200) {
var version = parse(res.body)
.querySelector('p.subtitle.is-6')
?.querySelector('a')
?.attributes['href']
?.split('/')
.last;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(
version, ['https://mullvad.net/download/app/apk/latest']);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
}
}
class IzzyOnDroid implements AppSource {
@override
late String host = 'android.izzysoft.de';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL;
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
Response res = await get(Uri.parse(standardUrl));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var multipleVersionApkUrls = parsedHtml
.querySelectorAll('a')
.where((element) =>
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
false)
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
.toList();
if (multipleVersionApkUrls.isEmpty) {
throw noAPKFound;
}
var version = parsedHtml
.querySelector('#keydata')
?.querySelectorAll('b')
.where(
(element) => element.innerHtml.toLowerCase().contains('version'))
.toList()[0]
.parentNode
?.parentNode
?.children[1]
.innerHtml;
if (version == null) {
throw couldNotFindLatestVersion;
}
return APKDetails(version, [multipleVersionApkUrls[0]]);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
}
}
class SourceProvider {
List<AppSource> sources = [
GitHub(),
GitLab(),
FDroid(),
Mullvad(),
Signal(),
IzzyOnDroid()
];
List<MassAppSource> massSources = [GitHubStars()];
// Add more source classes here so they are available via the service // Add more source classes here so they are available via the service
AppSource getSource(String url) { AppSource getSource(String url) {
if (url.toLowerCase().contains('://github.com')) { AppSource? source;
return GitHub(); for (var s in sources) {
} else if (url.toLowerCase().contains('://gitlab.com')) { if (url.toLowerCase().contains('://${s.host}')) {
return GitLab(); source = s;
break;
}
} }
throw 'URL does not match a known source'; if (source == null) {
throw 'URL does not match a known source';
}
return source;
} }
Future<App> getApp(String url) async { Future<App> getApp(String url) async {
@ -219,12 +434,64 @@ class sourceProvider {
AppNames names = source.getAppNames(standardUrl); AppNames names = source.getAppNames(standardUrl);
APKDetails apk = await source.getLatestAPKDetails(standardUrl); APKDetails apk = await source.getLatestAPKDetails(standardUrl);
return App( return App(
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.sourceId}', '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
standardUrl, standardUrl,
names.author[0].toUpperCase() + names.author.substring(1), names.author[0].toUpperCase() + names.author.substring(1),
names.name[0].toUpperCase() + names.name.substring(1), names.name[0].toUpperCase() + names.name.substring(1),
null, null,
apk.version, apk.version,
apk.apkUrls); 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';
}
} }
} }

View File

@ -92,13 +92,55 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.7.8" 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:
@ -120,6 +162,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.4" version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -152,7 +201,7 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "9.8.0+1" version: "9.9.1"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
@ -167,6 +216,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -323,7 +379,7 @@ 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: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@ -379,7 +435,7 @@ packages:
name: plugin_platform_interface name: plugin_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
process: process:
dependency: transitive dependency: transitive
description: description:
@ -407,7 +463,7 @@ packages:
name: shared_preferences_android name: shared_preferences_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.12" version: "2.0.13"
shared_preferences_ios: shared_preferences_ios:
dependency: transitive dependency: transitive
description: description:
@ -435,7 +491,7 @@ packages:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0" version: "2.1.0"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@ -496,7 +552,7 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.12" version: "0.4.14"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@ -524,7 +580,7 @@ packages:
name: url_launcher_android name: url_launcher_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.17" version: "6.0.19"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -573,7 +629,7 @@ packages:
name: vector_math name: vector_math
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.3"
webview_flutter: webview_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@ -587,28 +643,28 @@ packages:
name: webview_flutter_android name: webview_flutter_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.9.5" version: "2.10.1"
webview_flutter_platform_interface: webview_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_platform_interface name: webview_flutter_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.2" 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:
@ -639,4 +695,4 @@ packages:
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.19.0-79.0.dev <3.0.0" dart: ">=2.19.0-79.0.dev <3.0.0"
flutter: ">=3.1.0-0.0.pre.1036" flutter: ">=3.3.0"

View File

@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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.3+4 # When changing this, update the tag in main() accordingly version: 0.2.1+12 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.19.0-79.0.dev <3.0.0' sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -35,21 +35,23 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.5
path_provider: ^2.0.11 path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^9.8.0+1 flutter_local_notifications: ^9.9.1
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
workmanager: ^0.5.0 workmanager: ^0.5.0
dynamic_color: ^1.5.3 dynamic_color: ^1.5.4
install_plugin_v2: ^1.0.0 # Try replacing this install_plugin_v2: ^1.0.0 # Try replacing this
html: ^0.15.0 html: ^0.15.0
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
url_launcher: ^6.1.5 url_launcher: ^6.1.5
permission_handler: ^10.0.0 permission_handler: ^10.0.0
fluttertoast: ^8.0.9 fluttertoast: ^8.0.9
device_info_plus: ^4.1.2
file_picker: ^5.1.0
dev_dependencies: dev_dependencies:
@ -62,13 +64,13 @@ dev_dependencies:
# activated in the `analysis_options.yaml` file located at the root of your # activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^2.0.0 flutter_lints: ^2.0.1
flutter_icons: flutter_icons:
android: true android: true
image_path: "assets/icon.png" image_path: "assets/graphics/icon.png"
adaptive_icon_background: "#FFFFFF" adaptive_icon_background: "#FFFFFF"
adaptive_icon_foreground: "assets/icon.png" adaptive_icon_foreground: "assets/graphics/icon.png"
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec