Compare commits
36 Commits
v0.1.1-bet
...
v0.1.9-bet
Author | SHA1 | Date | |
---|---|---|---|
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 |
20
README.md
@ -1,17 +1,25 @@
|
|||||||
# Obtainium
|
#  Obtainium
|
||||||
|
|
||||||
Get Android App Updates Directly From the Source.
|
Get Android App Updates Directly From the Source.
|
||||||
|
|
||||||
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
|
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
|
||||||
|
|
||||||
Currently supported App sources:
|
|
||||||
- GitHub
|
|
||||||
|
|
||||||
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
||||||
|
|
||||||
***Work In Progress - Far from ready.***
|
Currently supported App sources:
|
||||||
|
- [GitHub](https://github.com/)
|
||||||
|
- [GitLab](https://gitlab.com/)
|
||||||
|
- [F-Droid](https://f-droid.org/)
|
||||||
|
- [Mullvad](https://mullvad.net/en/)
|
||||||
|
- [Signal](https://signal.org/)
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||||
- Already installed apps are not detected, for the above reason along with the fact that App sources do not provide App IDs (like `org.example.app`) to allow for comparisons.
|
|
||||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||||
|
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
|
||||||
|
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||||
|
@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion flutter.compileSdkVersion
|
compileSdkVersion 33
|
||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@ -54,7 +54,7 @@ android {
|
|||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||||
minSdkVersion 23
|
minSdkVersion 23
|
||||||
targetSdkVersion 32
|
targetSdkVersion 33
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 4.1 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 |
124
lib/main.dart
@ -1,78 +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';
|
||||||
|
|
||||||
|
const String currentReleaseTag =
|
||||||
|
'v0.1.9-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void backgroundUpdateCheck() {
|
void bgTaskCallback() {
|
||||||
Workmanager().executeTask((task, inputData) async {
|
// Background update checking process
|
||||||
var appsProvider = AppsProvider(bg: true);
|
Workmanager().executeTask((task, taskName) async {
|
||||||
await appsProvider.notify(
|
var notificationsProvider = NotificationsProvider();
|
||||||
4,
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
'Checking for Updates',
|
|
||||||
'',
|
|
||||||
'BG_UPDATE_CHECK',
|
|
||||||
'Checking for Updates',
|
|
||||||
'Transient notification that appears when checking for updates',
|
|
||||||
important: false);
|
|
||||||
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) {
|
||||||
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) {
|
} catch (e) {
|
||||||
await appsProvider.downloaderNotifications.cancel(5);
|
notificationsProvider.notify(
|
||||||
await appsProvider.notify(
|
ErrorCheckingUpdatesNotification(e.toString()),
|
||||||
5,
|
cancelExisting: true);
|
||||||
'Error Checking for Updates',
|
|
||||||
e.toString(),
|
|
||||||
'BG_UPDATE_CHECK_ERROR',
|
|
||||||
'Error Checking for Updates',
|
|
||||||
'A notification that shows when background update checking fails',
|
|
||||||
important: false);
|
|
||||||
return Future.value(false);
|
return Future.value(false);
|
||||||
} finally {
|
} finally {
|
||||||
await appsProvider.downloaderNotifications.cancel(4);
|
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(),
|
||||||
));
|
));
|
||||||
@ -85,40 +74,37 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DynamicColorBuilder(
|
|
||||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
|
||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
|
||||||
appsProvider.deleteSavedAPKs();
|
|
||||||
// Initialize the settings provider (if needed) and perform first-run actions if needed
|
|
||||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
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
|
||||||
|
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
||||||
|
frequency: Duration(minutes: settingsProvider.updateInterval),
|
||||||
|
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
||||||
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
|
existingWorkPolicy: ExistingWorkPolicy.replace);
|
||||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||||
if (isFirstRun) {
|
if (isFirstRun) {
|
||||||
appsProvider
|
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||||
.notify(
|
Permission.notification.request();
|
||||||
3,
|
|
||||||
'Permission Notification',
|
|
||||||
'This is a transient notification used to trigger the Android 13 notification permission prompt',
|
|
||||||
'PERMISSION_NOTIFICATION',
|
|
||||||
'Permission Notifications',
|
|
||||||
'A transient notification used to trigger the Android 13 notification permission prompt',
|
|
||||||
important: false)
|
|
||||||
.whenComplete(() {
|
|
||||||
appsProvider.downloaderNotifications.cancel(3);
|
|
||||||
});
|
|
||||||
appsProvider.saveApp(App(
|
appsProvider.saveApp(App(
|
||||||
'imranr98_obtainium_github',
|
'imranr98_obtainium_${GitHub().host}',
|
||||||
'https://github.com/ImranR98/Obtainium',
|
'https://github.com/ImranR98/Obtainium',
|
||||||
'ImranR98',
|
'ImranR98',
|
||||||
'Obtainium',
|
'Obtainium',
|
||||||
'v0.1.1-beta', // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
currentReleaseTag,
|
||||||
'v0.1.1-beta',
|
currentReleaseTag,
|
||||||
''));
|
[],
|
||||||
|
0));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 &&
|
||||||
@ -131,7 +117,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(
|
||||||
@ -144,7 +129,8 @@ class MyApp extends StatelessWidget {
|
|||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: settingsProvider.theme == ThemeSettings.light
|
colorScheme: settingsProvider.theme == ThemeSettings.light
|
||||||
? lightColorScheme
|
? lightColorScheme
|
||||||
: darkColorScheme),
|
: darkColorScheme,
|
||||||
|
fontFamily: 'Metropolis'),
|
||||||
home: const HomePage());
|
home: const HomePage());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/services/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/services/source_service.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class AddAppPage extends StatefulWidget {
|
class AddAppPage extends StatefulWidget {
|
||||||
const AddAppPage({super.key});
|
const AddAppPage({super.key});
|
||||||
@ -18,16 +21,21 @@ 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(
|
||||||
|
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,25 +48,31 @@ 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.mediumImpact();
|
||||||
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 =
|
||||||
|
context.read<AppsProvider>();
|
||||||
|
var settingsProvider =
|
||||||
|
context.read<SettingsProvider>();
|
||||||
if (appsProvider.apps.containsKey(app.id)) {
|
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(
|
||||||
@ -67,6 +81,7 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
AppPage(appId: app.id)));
|
AppPage(appId: app.id)));
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString())),
|
SnackBar(content: Text(e.toString())),
|
||||||
@ -81,10 +96,39 @@ 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,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:obtainium/services/apps_provider.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@ -16,6 +19,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||||
if (app?.app.installedVersion != null) {
|
if (app?.app.installedVersion != null) {
|
||||||
appsProvider.getUpdate(app!.app.id);
|
appsProvider.getUpdate(app!.app.id);
|
||||||
@ -24,8 +28,57 @@ 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
|
||||||
|
? 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(
|
||||||
@ -44,11 +97,17 @@ class _AppPageState extends State<AppPage> {
|
|||||||
appsProvider
|
appsProvider
|
||||||
.checkAppObjectForUpdate(
|
.checkAppObjectForUpdate(
|
||||||
app!.app)) &&
|
app!.app)) &&
|
||||||
app?.downloadProgress == null
|
!appsProvider.areDownloadsRunning()
|
||||||
? () {
|
? () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
appsProvider
|
appsProvider
|
||||||
.downloadAndInstallLatestApp(
|
.downloadAndInstallLatestApp(
|
||||||
app!.app.id);
|
[app!.app.id],
|
||||||
|
context).then((res) {
|
||||||
|
if (res && mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: Text(app?.app.installedVersion == null
|
child: Text(app?.app.installedVersion == null
|
||||||
@ -59,6 +118,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
onPressed: app?.downloadProgress != null
|
onPressed: app?.downloadProgress != null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
@ -69,6 +129,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
appsProvider
|
appsProvider
|
||||||
.removeApp(app!.app.id)
|
.removeApp(app!.app.id)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
@ -81,6 +142,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
child: const Text('Remove')),
|
child: const Text('Remove')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: const Text('Cancel'))
|
child: const Text('Cancel'))
|
||||||
@ -89,8 +151,10 @@ class _AppPageState extends State<AppPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: Theme.of(context).errorColor,
|
foregroundColor:
|
||||||
surfaceTintColor: Theme.of(context).errorColor),
|
Theme.of(context).colorScheme.error,
|
||||||
|
surfaceTintColor:
|
||||||
|
Theme.of(context).colorScheme.error),
|
||||||
child: const Text('Remove'),
|
child: const Text('Remove'),
|
||||||
),
|
),
|
||||||
])),
|
])),
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/services/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class AppsPage extends StatefulWidget {
|
class AppsPage extends StatefulWidget {
|
||||||
@ -14,21 +16,23 @@ 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.checkUpdates();
|
|
||||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
||||||
|
|
||||||
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
|
||||||
: () {
|
: () {
|
||||||
for (var e in existingUpdateAppIds) {
|
HapticFeedback.heavyImpact();
|
||||||
appsProvider.downloadAndInstallLatestApp(e);
|
context
|
||||||
}
|
.read<SettingsProvider>()
|
||||||
|
.getInstallPermission()
|
||||||
|
.then((_) {
|
||||||
|
appsProvider.downloadAndInstallLatestApp(
|
||||||
|
existingUpdateAppIds, context);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.update),
|
icon: const Icon(Icons.update),
|
||||||
label: const Text('Update All')),
|
label: const Text('Update All')),
|
||||||
@ -38,10 +42,13 @@ 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: appsProvider.apps.values
|
||||||
.map(
|
.map(
|
||||||
@ -51,7 +58,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
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
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/settings.dart';
|
import 'package:obtainium/pages/settings.dart';
|
||||||
@ -20,22 +21,34 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return WillPopScope(
|
||||||
|
child: Scaffold(
|
||||||
appBar: AppBar(title: const Text('Obtainium')),
|
appBar: AppBar(title: const Text('Obtainium')),
|
||||||
body: pages.elementAt(selectedIndex),
|
body: pages.elementAt(selectedIndex),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings), label: 'Settings'),
|
||||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
||||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
||||||
],
|
],
|
||||||
onDestinationSelected: (int index) {
|
onDestinationSelected: (int index) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
onWillPop: () async {
|
||||||
|
if (selectedIndex != 1) {
|
||||||
|
setState(() {
|
||||||
|
selectedIndex = 1;
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:obtainium/services/settings_provider.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/providers/apps_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';
|
||||||
|
|
||||||
@ -13,6 +17,7 @@ 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();
|
||||||
@ -66,17 +71,186 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
settingsProvider.colour = value;
|
settingsProvider.colour = value;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
const SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
DropdownButtonFormField(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Background Update Checking Interval'),
|
||||||
|
value: settingsProvider.updateInterval,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 15,
|
||||||
|
child: Text('15 Minutes'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 30,
|
||||||
|
child: Text('30 Minutes'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 60,
|
||||||
|
child: Text('1 Hour'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 360,
|
||||||
|
child: Text('6 Hours'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 720,
|
||||||
|
child: Text('12 Hours'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 1440,
|
||||||
|
child: Text('1 Day'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
settingsProvider.updateInterval = 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 SizedBox(
|
||||||
|
height: 16,
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: appsProvider.apps.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
appsProvider.exportApps().then((String path) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Exported to $path')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Export App List')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
final jsonInputController =
|
||||||
|
TextEditingController();
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Import App List'),
|
||||||
|
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: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
if (formKey.currentState!
|
||||||
|
.validate()) {
|
||||||
|
appsProvider
|
||||||
|
.importApps(
|
||||||
|
jsonInputController
|
||||||
|
.value.text)
|
||||||
|
.then((value) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||||
|
);
|
||||||
|
}).catchError((e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content:
|
||||||
|
Text(e.toString())),
|
||||||
|
);
|
||||||
|
}).whenComplete(() {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Import')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Import App List'))
|
||||||
|
],
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
TextButton.icon(
|
||||||
|
style: ButtonStyle(
|
||||||
|
foregroundColor:
|
||||||
|
MaterialStateProperty.resolveWith<Color>(
|
||||||
|
(Set<MaterialState> states) {
|
||||||
|
return Colors.grey;
|
||||||
|
}),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
mode: LaunchMode.externalApplication);
|
mode: LaunchMode.externalApplication);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.code),
|
icon: const Icon(Icons.code),
|
||||||
label: const Text('Source'),
|
label: Text(
|
||||||
|
'Source',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
392
lib/providers/apps_provider.dart
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
// Manages state related to the list of Apps tracked by Obtainium,
|
||||||
|
// Exposes related functions such as those used to add, remove, download, and install Apps.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||||
|
|
||||||
|
class AppInMemory {
|
||||||
|
late App app;
|
||||||
|
double? downloadProgress;
|
||||||
|
|
||||||
|
AppInMemory(this.app, this.downloadProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApkFile {
|
||||||
|
String appId;
|
||||||
|
File file;
|
||||||
|
ApkFile(this.appId, this.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppsProvider with ChangeNotifier {
|
||||||
|
// In memory App state (should always be kept in sync with local storage versions)
|
||||||
|
Map<String, AppInMemory> apps = {};
|
||||||
|
bool loadingApps = false;
|
||||||
|
bool gettingUpdates = false;
|
||||||
|
|
||||||
|
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||||
|
bool isForeground = true;
|
||||||
|
late Stream<FGBGType> foregroundStream;
|
||||||
|
late StreamSubscription<FGBGType> foregroundSubscription;
|
||||||
|
|
||||||
|
AppsProvider(
|
||||||
|
{bool shouldLoadApps = false,
|
||||||
|
bool shouldCheckUpdatesAfterLoad = false,
|
||||||
|
bool shouldDeleteAPKs = false}) {
|
||||||
|
// Subscribe to changes in the app foreground status
|
||||||
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
|
foregroundSubscription = foregroundStream.listen((event) async {
|
||||||
|
isForeground = event == FGBGType.foreground;
|
||||||
|
if (isForeground) await loadApps();
|
||||||
|
});
|
||||||
|
if (shouldDeleteAPKs) {
|
||||||
|
deleteSavedAPKs();
|
||||||
|
}
|
||||||
|
if (shouldLoadApps) {
|
||||||
|
loadApps().then((_) {
|
||||||
|
if (shouldCheckUpdatesAfterLoad) {
|
||||||
|
checkUpdates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
||||||
|
StreamedResponse response =
|
||||||
|
await Client().send(Request('GET', Uri.parse(apkUrl)));
|
||||||
|
File downloadFile =
|
||||||
|
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
||||||
|
if (downloadFile.existsSync()) {
|
||||||
|
downloadFile.deleteSync();
|
||||||
|
}
|
||||||
|
var length = response.contentLength;
|
||||||
|
var received = 0;
|
||||||
|
var sink = downloadFile.openWrite();
|
||||||
|
|
||||||
|
await response.stream.map((s) {
|
||||||
|
received += s.length;
|
||||||
|
apps[appId]!.downloadProgress =
|
||||||
|
(length != null ? received / length * 100 : 30);
|
||||||
|
notifyListeners();
|
||||||
|
return s;
|
||||||
|
}).pipe(sink);
|
||||||
|
|
||||||
|
await sink.close();
|
||||||
|
apps[appId]!.downloadProgress = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
downloadFile.deleteSync();
|
||||||
|
throw response.reasonPhrase ?? 'Unknown Error';
|
||||||
|
}
|
||||||
|
return ApkFile(appId, downloadFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool areDownloadsRunning() => apps.values
|
||||||
|
.where((element) => element.downloadProgress != null)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
|
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
||||||
|
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
||||||
|
// Returns upon successful download, regardless of installation result
|
||||||
|
Future<bool> downloadAndInstallLatestApp(
|
||||||
|
List<String> appIds, BuildContext context) async {
|
||||||
|
NotificationsProvider notificationsProvider =
|
||||||
|
context.read<NotificationsProvider>();
|
||||||
|
Map<String, String> appsToInstall = {};
|
||||||
|
for (var id in appIds) {
|
||||||
|
if (apps[id] == null) {
|
||||||
|
throw 'App not found';
|
||||||
|
}
|
||||||
|
// If the App has more than one APK, the user should pick one
|
||||||
|
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
||||||
|
if (apps[id]!.app.apkUrls.length > 1) {
|
||||||
|
apkUrl = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If the picked APK comes from an origin different from the source, get user confirmation
|
||||||
|
if (apkUrl != null &&
|
||||||
|
!apkUrl.toLowerCase().startsWith(apps[id]!.app.url.toLowerCase())) {
|
||||||
|
if (await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext ctx) {
|
||||||
|
return APKOriginWarningDialog(
|
||||||
|
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
|
||||||
|
}) !=
|
||||||
|
true) {
|
||||||
|
apkUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (apkUrl != null) {
|
||||||
|
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||||
|
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||||
|
apps[id]!.app.preferredApkIndex = urlInd;
|
||||||
|
await saveApp(apps[id]!.app);
|
||||||
|
}
|
||||||
|
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
||||||
|
.map((entry) => downloadApp(entry.value, entry.key)));
|
||||||
|
|
||||||
|
if (!isForeground) {
|
||||||
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
|
cancelExisting: true);
|
||||||
|
await FGBGEvents.stream.first == FGBGType.foreground;
|
||||||
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
|
// We need to wait for the App to come to the foreground to install it
|
||||||
|
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
||||||
|
// https://github.com/flutter/flutter/issues/13937
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
||||||
|
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||||
|
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
|
||||||
|
for (var f in downloadedFiles) {
|
||||||
|
await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium');
|
||||||
|
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
||||||
|
await saveApp(apps[f.appId]!.app);
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadedFiles.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> getAppsDir() async {
|
||||||
|
Directory appsDir = Directory(
|
||||||
|
'${(await getExternalStorageDirectory())?.path as String}/app_data');
|
||||||
|
if (!appsDir.existsSync()) {
|
||||||
|
appsDir.createSync();
|
||||||
|
}
|
||||||
|
return appsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteSavedAPKs() async {
|
||||||
|
(await getExternalStorageDirectory())
|
||||||
|
?.listSync()
|
||||||
|
.where((element) => element.path.endsWith('.apk'))
|
||||||
|
.forEach((element) {
|
||||||
|
element.deleteSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadApps() async {
|
||||||
|
loadingApps = true;
|
||||||
|
notifyListeners();
|
||||||
|
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||||
|
.listSync()
|
||||||
|
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||||
|
.toList();
|
||||||
|
apps.clear();
|
||||||
|
for (int i = 0; i < appFiles.length; i++) {
|
||||||
|
App app =
|
||||||
|
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
||||||
|
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
|
||||||
|
}
|
||||||
|
loadingApps = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveApp(App app) async {
|
||||||
|
File('${(await getAppsDir()).path}/${app.id}.json')
|
||||||
|
.writeAsStringSync(jsonEncode(app.toJson()));
|
||||||
|
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
|
||||||
|
ifAbsent: () => AppInMemory(app, null));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeApp(String appId) async {
|
||||||
|
File file = File('${(await getAppsDir()).path}/$appId.json');
|
||||||
|
if (file.existsSync()) {
|
||||||
|
file.deleteSync();
|
||||||
|
}
|
||||||
|
if (apps.containsKey(appId)) {
|
||||||
|
apps.remove(appId);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool checkAppObjectForUpdate(App app) {
|
||||||
|
if (!apps.containsKey(app.id)) {
|
||||||
|
throw 'App not found';
|
||||||
|
}
|
||||||
|
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<App?> getUpdate(String appId) async {
|
||||||
|
App? currentApp = apps[appId]!.app;
|
||||||
|
App newApp = await SourceProvider().getApp(currentApp.url);
|
||||||
|
if (newApp.latestVersion != currentApp.latestVersion) {
|
||||||
|
newApp.installedVersion = currentApp.installedVersion;
|
||||||
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
|
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||||
|
}
|
||||||
|
await saveApp(newApp);
|
||||||
|
return newApp;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<App>> checkUpdates() async {
|
||||||
|
List<App> updates = [];
|
||||||
|
if (!gettingUpdates) {
|
||||||
|
gettingUpdates = true;
|
||||||
|
|
||||||
|
List<String> appIds = apps.keys.toList();
|
||||||
|
for (int i = 0; i < appIds.length; i++) {
|
||||||
|
App? newApp = await getUpdate(appIds[i]);
|
||||||
|
if (newApp != null) {
|
||||||
|
updates.add(newApp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gettingUpdates = false;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> getExistingUpdates() {
|
||||||
|
List<String> updateAppIds = [];
|
||||||
|
List<String> appIds = apps.keys.toList();
|
||||||
|
for (int i = 0; i < appIds.length; i++) {
|
||||||
|
App? app = apps[appIds[i]]!.app;
|
||||||
|
if (app.installedVersion != app.latestVersion) {
|
||||||
|
updateAppIds.add(app.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updateAppIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> exportApps() async {
|
||||||
|
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
||||||
|
String path = 'Downloads';
|
||||||
|
if (!exportDir.existsSync()) {
|
||||||
|
exportDir = await getExternalStorageDirectory();
|
||||||
|
path = exportDir!.path;
|
||||||
|
}
|
||||||
|
File export = File(
|
||||||
|
'${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json');
|
||||||
|
export.writeAsStringSync(
|
||||||
|
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> importApps(String appsJSON) async {
|
||||||
|
// File picker does not work in android 13, so the user must paste the JSON directly into Obtainium to import Apps
|
||||||
|
List<App> importedApps = (jsonDecode(appsJSON) as List<dynamic>)
|
||||||
|
.map((e) => App.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
for (App a in importedApps) {
|
||||||
|
a.installedVersion =
|
||||||
|
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
|
||||||
|
await saveApp(a);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
return importedApps.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
foregroundSubscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class APKPicker extends StatefulWidget {
|
||||||
|
const APKPicker({super.key, required this.app, this.initVal});
|
||||||
|
|
||||||
|
final App app;
|
||||||
|
final String? initVal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<APKPicker> createState() => _APKPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _APKPickerState extends State<APKPicker> {
|
||||||
|
String? apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
apkUrl ??= widget.initVal;
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Pick an APK'),
|
||||||
|
content: Column(children: [
|
||||||
|
Text('${widget.app.name} has more than one package:'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
||||||
|
title: Text(Uri.parse(u).pathSegments.last),
|
||||||
|
value: u,
|
||||||
|
groupValue: apkUrl,
|
||||||
|
onChanged: (String? val) {
|
||||||
|
setState(() {
|
||||||
|
apkUrl = val;
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
Navigator.of(context).pop(apkUrl);
|
||||||
|
},
|
||||||
|
child: const Text('Continue'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class APKOriginWarningDialog extends StatefulWidget {
|
||||||
|
const APKOriginWarningDialog(
|
||||||
|
{super.key, required this.sourceUrl, required this.apkUrl});
|
||||||
|
|
||||||
|
final String sourceUrl;
|
||||||
|
final String apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Warning'),
|
||||||
|
content: Text(
|
||||||
|
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: const Text('Continue'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
127
lib/providers/notifications_provider.dart
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
// Exposes functions that can be used to send notifications to the user
|
||||||
|
// Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app
|
||||||
|
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
|
||||||
|
class ObtainiumNotification {
|
||||||
|
late int id;
|
||||||
|
late String title;
|
||||||
|
late String message;
|
||||||
|
late String channelCode;
|
||||||
|
late String channelName;
|
||||||
|
late String channelDescription;
|
||||||
|
Importance importance;
|
||||||
|
|
||||||
|
ObtainiumNotification(this.id, this.title, this.message, this.channelCode,
|
||||||
|
this.channelName, this.channelDescription, this.importance);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateNotification extends ObtainiumNotification {
|
||||||
|
UpdateNotification(List<App> updates)
|
||||||
|
: super(
|
||||||
|
2,
|
||||||
|
'Updates Available',
|
||||||
|
'',
|
||||||
|
'UPDATES_AVAILABLE',
|
||||||
|
'Updates Available',
|
||||||
|
'Notifies the user that updates are available for one or more Apps tracked by Obtainium',
|
||||||
|
Importance.max) {
|
||||||
|
message = updates.length == 1
|
||||||
|
? '${updates[0].name} has an update.'
|
||||||
|
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
|
||||||
|
ErrorCheckingUpdatesNotification(String error)
|
||||||
|
: super(
|
||||||
|
5,
|
||||||
|
'Error Checking for Updates',
|
||||||
|
error,
|
||||||
|
'BG_UPDATE_CHECK_ERROR',
|
||||||
|
'Error Checking for Updates',
|
||||||
|
'A notification that shows when background update checking fails',
|
||||||
|
Importance.high);
|
||||||
|
}
|
||||||
|
|
||||||
|
final completeInstallationNotification = ObtainiumNotification(
|
||||||
|
1,
|
||||||
|
'Complete App Installation',
|
||||||
|
'Obtainium must be open to install Apps',
|
||||||
|
'COMPLETE_INSTALL',
|
||||||
|
'Complete App Installation',
|
||||||
|
'Asks the user to return to Obtanium to finish installing an App',
|
||||||
|
Importance.max);
|
||||||
|
|
||||||
|
final checkingUpdatesNotification = ObtainiumNotification(
|
||||||
|
4,
|
||||||
|
'Checking for Updates',
|
||||||
|
'',
|
||||||
|
'BG_UPDATE_CHECK',
|
||||||
|
'Checking for Updates',
|
||||||
|
'Transient notification that appears when checking for updates',
|
||||||
|
Importance.min);
|
||||||
|
|
||||||
|
class NotificationsProvider {
|
||||||
|
FlutterLocalNotificationsPlugin notifications =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
bool isInitialized = false;
|
||||||
|
|
||||||
|
Map<Importance, Priority> importanceToPriority = {
|
||||||
|
Importance.defaultImportance: Priority.defaultPriority,
|
||||||
|
Importance.high: Priority.high,
|
||||||
|
Importance.low: Priority.low,
|
||||||
|
Importance.max: Priority.max,
|
||||||
|
Importance.min: Priority.min,
|
||||||
|
Importance.none: Priority.min,
|
||||||
|
Importance.unspecified: Priority.defaultPriority
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
isInitialized = await notifications.initialize(const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('ic_notification'))) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancel(int id) async {
|
||||||
|
if (!isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
await notifications.cancel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> notifyRaw(
|
||||||
|
int id,
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
String channelCode,
|
||||||
|
String channelName,
|
||||||
|
String channelDescription,
|
||||||
|
Importance importance,
|
||||||
|
{bool cancelExisting = false}) async {
|
||||||
|
if (cancelExisting) {
|
||||||
|
await cancel(id);
|
||||||
|
}
|
||||||
|
if (!isInitialized) {
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
await notifications.show(
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(channelCode, channelName,
|
||||||
|
channelDescription: channelDescription,
|
||||||
|
importance: importance,
|
||||||
|
priority: importanceToPriority[importance]!,
|
||||||
|
groupKey: 'dev.imranr.obtainium.$channelCode')));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> notify(ObtainiumNotification notif,
|
||||||
|
{bool cancelExisting = false}) =>
|
||||||
|
notifyRaw(notif.id, notif.title, notif.message, notif.channelCode,
|
||||||
|
notif.channelName, notif.channelDescription, notif.importance,
|
||||||
|
cancelExisting: cancelExisting);
|
||||||
|
}
|
@ -1,4 +1,8 @@
|
|||||||
|
// Exposes functions used to save/load app settings
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
enum ThemeSettings { system, light, dark }
|
enum ThemeSettings { system, light, dark }
|
||||||
@ -22,7 +26,6 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set theme(ThemeSettings t) {
|
set theme(ThemeSettings t) {
|
||||||
print(t);
|
|
||||||
prefs?.setInt('theme', t.index);
|
prefs?.setInt('theme', t.index);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@ -37,11 +40,42 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAndFlipFirstRun() {
|
int get updateInterval {
|
||||||
|
return prefs?.getInt('updateInterval') ?? 1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
set updateInterval(int min) {
|
||||||
|
prefs?.setInt('updateInterval', min < 15 ? 15 : min);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool checkAndFlipFirstRun() {
|
||||||
bool result = prefs?.getBool('firstRun') ?? true;
|
bool result = prefs?.getBool('firstRun') ?? true;
|
||||||
if (result) {
|
if (result) {
|
||||||
prefs?.setBool('firstRun', false);
|
prefs?.setBool('firstRun', false);
|
||||||
}
|
}
|
||||||
return result;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
386
lib/providers/source_provider.dart
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
// 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) {
|
||||||
|
var name = Uri.parse(standardUrl).pathSegments.last;
|
||||||
|
return AppNames(name, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 SourceProvider {
|
||||||
|
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||||
|
}
|
@ -1,233 +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> 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 SourceService().getApp(currentApp.url);
|
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
IsolateNameServer.removePortNameMapping('downloader_send_port');
|
|
||||||
foregroundSubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,166 +0,0 @@
|
|||||||
// Exposes functions related to interacting with App sources and retrieving App info
|
|
||||||
// Stateless - not a provider
|
|
||||||
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
import 'package:html/parser.dart';
|
|
||||||
|
|
||||||
// Sub-classes used in App Source
|
|
||||||
|
|
||||||
class AppNames {
|
|
||||||
late String author;
|
|
||||||
late String name;
|
|
||||||
|
|
||||||
AppNames(this.author, this.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
class APKDetails {
|
|
||||||
late String version;
|
|
||||||
late String downloadUrl;
|
|
||||||
|
|
||||||
APKDetails(this.version, this.downloadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// App Source abstract class (diff. implementations for GitHub, GitLab, etc.)
|
|
||||||
|
|
||||||
abstract class AppSource {
|
|
||||||
late String sourceId;
|
|
||||||
String standardizeURL(String url);
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
|
||||||
AppNames getAppNames(String standardUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
escapeRegEx(String s) {
|
|
||||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
|
||||||
return "\\${x[0]}";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// App class
|
|
||||||
|
|
||||||
class App {
|
|
||||||
late String id;
|
|
||||||
late String url;
|
|
||||||
late String author;
|
|
||||||
late String name;
|
|
||||||
String? installedVersion;
|
|
||||||
late String latestVersion;
|
|
||||||
late String apkUrl;
|
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
|
||||||
this.latestVersion, this.apkUrl);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrl';
|
|
||||||
}
|
|
||||||
|
|
||||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
|
||||||
json['id'] as String,
|
|
||||||
json['url'] as String,
|
|
||||||
json['author'] as String,
|
|
||||||
json['name'] as String,
|
|
||||||
json['installedVersion'] == null
|
|
||||||
? null
|
|
||||||
: json['installedVersion'] as String,
|
|
||||||
json['latestVersion'] as String,
|
|
||||||
json['apkUrl'] as String);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'url': url,
|
|
||||||
'author': author,
|
|
||||||
'name': name,
|
|
||||||
'installedVersion': installedVersion,
|
|
||||||
'latestVersion': latestVersion,
|
|
||||||
'apkUrl': apkUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Specific App Source classes
|
|
||||||
|
|
||||||
class GitHub implements AppSource {
|
|
||||||
@override
|
|
||||||
String sourceId = 'github';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String standardizeURL(String url) {
|
|
||||||
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
|
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
|
||||||
if (match == null) {
|
|
||||||
throw 'Not a valid URL';
|
|
||||||
}
|
|
||||||
return url.substring(0, match.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
String convertURL(String url, String replaceText) {
|
|
||||||
int tempInd1 = url.indexOf('://') + 3;
|
|
||||||
int tempInd2 = url.substring(tempInd1).indexOf('/') + tempInd1;
|
|
||||||
return '${url.substring(0, tempInd1)}$replaceText${url.substring(tempInd2)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
var standardUri = Uri.parse(standardUrl);
|
|
||||||
var parsedHtml = parse(res.body);
|
|
||||||
var apkUrlList = parsedHtml.querySelectorAll('a').where((element) {
|
|
||||||
return RegExp(
|
|
||||||
'^${escapeRegEx(standardUri.path)}/releases/download/*/(?!/).*.apk\$',
|
|
||||||
caseSensitive: false)
|
|
||||||
.hasMatch(element.attributes['href']!);
|
|
||||||
}).toList();
|
|
||||||
String? version = parsedHtml
|
|
||||||
.querySelector('.octicon-tag')
|
|
||||||
?.nextElementSibling
|
|
||||||
?.innerHtml
|
|
||||||
.trim();
|
|
||||||
if (apkUrlList.isEmpty || version == null) {
|
|
||||||
throw 'No APK found';
|
|
||||||
}
|
|
||||||
return APKDetails(
|
|
||||||
version, '${standardUri.origin}${apkUrlList[0].attributes['href']!}');
|
|
||||||
} else {
|
|
||||||
throw 'Unable to fetch release info';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AppNames getAppNames(String standardUrl) {
|
|
||||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
|
||||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
|
||||||
return AppNames(names[0], names[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SourceService {
|
|
||||||
// Add more source classes here so they are available via the service
|
|
||||||
AppSource github = GitHub();
|
|
||||||
AppSource getSource(String url) {
|
|
||||||
if (url.toLowerCase().contains('://github.com')) {
|
|
||||||
return github;
|
|
||||||
}
|
|
||||||
throw 'URL does not match a known source';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
|
||||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
|
||||||
url.toLowerCase().indexOf('https://') != 0) {
|
|
||||||
url = 'https://$url';
|
|
||||||
}
|
|
||||||
if (url.toLowerCase().indexOf('https://www.') == 0) {
|
|
||||||
url = 'https://${url.substring(12)}';
|
|
||||||
}
|
|
||||||
AppSource source = getSource(url);
|
|
||||||
String standardUrl = source.standardizeURL(url);
|
|
||||||
AppNames names = source.getAppNames(standardUrl);
|
|
||||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
|
||||||
return App(
|
|
||||||
'${names.author}_${names.name}_${source.sourceId}',
|
|
||||||
standardUrl,
|
|
||||||
names.author[0].toUpperCase() + names.author.substring(1),
|
|
||||||
names.name[0].toUpperCase() + names.name.substring(1),
|
|
||||||
null,
|
|
||||||
apk.version,
|
|
||||||
apk.downloadUrl);
|
|
||||||
}
|
|
||||||
}
|
|
108
pubspec.lock
@ -91,14 +91,56 @@ packages:
|
|||||||
name: dbus
|
name: dbus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.8"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
device_info_plus_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_linux
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_macos
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_windows
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
dynamic_color:
|
dynamic_color:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dynamic_color
|
name: dynamic_color
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.3"
|
version: "1.5.4"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -119,7 +161,7 @@ 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"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -152,7 +194,7 @@ packages:
|
|||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.7.0"
|
version: "9.9.1"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -177,6 +219,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 +330,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:
|
||||||
@ -317,6 +366,41 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.0"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "10.0.0"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.4"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.7.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -393,7 +477,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 +538,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.13"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -531,7 +615,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,14 +629,14 @@ 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.0"
|
||||||
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:
|
||||||
@ -580,7 +664,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 +681,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"
|
||||||
|
17
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.1+2 # When changing this, update the tag in main() accordingly
|
version: 0.1.9+10 # 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,22 @@ dependencies:
|
|||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.5
|
||||||
path_provider: ^2.0.11
|
path_provider: ^2.0.11
|
||||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||||
flutter_local_notifications: ^9.7.0
|
flutter_local_notifications: ^9.9.1
|
||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
webview_flutter: ^3.0.4
|
webview_flutter: ^3.0.4
|
||||||
workmanager: ^0.5.0
|
workmanager: ^0.5.0
|
||||||
dynamic_color: ^1.5.3
|
dynamic_color: ^1.5.4
|
||||||
install_plugin_v2: ^1.0.0 # Try replacing this
|
install_plugin_v2: ^1.0.0 # Try replacing this
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
shared_preferences: ^2.0.15
|
shared_preferences: ^2.0.15
|
||||||
url_launcher: ^6.1.5
|
url_launcher: ^6.1.5
|
||||||
|
permission_handler: ^10.0.0
|
||||||
|
fluttertoast: ^8.0.9
|
||||||
|
device_info_plus: ^4.1.2
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@ -60,13 +63,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
|
||||||
|