Compare commits
40 Commits
v0.1.0-bet
...
v0.1.8-bet
Author | SHA1 | Date | |
---|---|---|---|
52b97662c6 | |||
f63da4b538 | |||
c30c692d87 | |||
d643d5a474 | |||
f8101a5d9f | |||
c2a7e4a0d2 | |||
285da7545b | |||
a5230acc11 | |||
53019818a6 | |||
1a04d39144 | |||
96c1ed612d | |||
4d75a6a361 | |||
30075add1c | |||
52b4e1fb96 | |||
f9044e20f1 | |||
7e5affe1b8 | |||
5bdab1b1e4 | |||
c14c4d2f14 | |||
5e785ae1d5 | |||
6c076751ab | |||
4253203dca | |||
7f1fd3c6c0 | |||
209f7ea516 | |||
09791979d5 | |||
e7170aca48 | |||
7932b909c0 | |||
4c4a9093e4 | |||
a6f290eb59 | |||
ecb1e7d367 | |||
10f1c3abe5 | |||
9459c96d48 | |||
2aca9d680b | |||
bd205dadc5 | |||
21ca18ce75 | |||
7afcf6a37b | |||
9dba372244 | |||
88b60fe362 | |||
0362cdf8ac | |||
aeada9635d | |||
ffe212ebf2 |
22
README.md
@ -1,17 +1,25 @@
|
||||
# Obtainium
|
||||
#  Obtainium
|
||||
|
||||
Get Android App Updates Directly From the Source.
|
||||
|
||||
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
|
||||
|
||||
Currently supported App sources:
|
||||
- GitHub
|
||||
|
||||
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
||||
|
||||
***Work In Progress - Far from ready.***
|
||||
Currently supported App sources:
|
||||
- [GitHub](https://github.com/)
|
||||
- [GitLab](https://gitlab.com/)
|
||||
- [F-Droid](https://f-droid.org/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
|
||||
## Limitations
|
||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
- Already installed apps are not detected, for the above reason along with the fact that App sources do not provide App IDs (like `org.example.app`) to allow for comparisons.
|
||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||
- 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 are either unavailable (e.g. Mullvad), insufficient (e.g. GitHub RSS) or subject to rate limits (e.g. GitHub API).
|
||||
|
||||
## Screenshots
|
||||
|
||||
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||
|
@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion flutter.compileSdkVersion
|
||||
compileSdkVersion 33
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
@ -54,7 +54,7 @@ android {
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 32
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
BIN
android/app/src/main/res/drawable/ic_notification.png
Normal file → Executable file
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.0 KiB |
3
android/app/src/main/res/raw/keep.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/*" />
|
BIN
assets/graphics/banner.png
Executable file
After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
BIN
assets/graphics/icon.psd
Executable file
BIN
assets/graphics/obtainium.psd
Executable file
BIN
assets/graphics/store-icon.png
Executable file
After Width: | Height: | Size: 21 KiB |
BIN
assets/screenshots/1.apps.png
Normal file
After Width: | Height: | Size: 99 KiB |
BIN
assets/screenshots/2.dark_theme.png
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
assets/screenshots/3.material_you.png
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
assets/screenshots/4.app.png
Normal file
After Width: | Height: | Size: 263 KiB |
BIN
assets/screenshots/5.apk_picker.png
Normal file
After Width: | Height: | Size: 200 KiB |
BIN
assets/screenshots/6.apk_install.png
Normal file
After Width: | Height: | Size: 192 KiB |
137
lib/main.dart
@ -1,54 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/home.dart';
|
||||
import 'package:obtainium/services/apps_provider.dart';
|
||||
import 'package:obtainium/services/settings_provider.dart';
|
||||
import 'package:obtainium/services/source_service.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
void backgroundUpdateCheck() {
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
var appsProvider = AppsProvider(bg: true);
|
||||
await appsProvider.loadApps();
|
||||
List<App> updates = await appsProvider.getUpdates();
|
||||
if (updates.isNotEmpty) {
|
||||
String message = updates.length == 1
|
||||
? '${updates[0].name} has an update.'
|
||||
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.';
|
||||
await appsProvider.downloaderNotifications.cancel(2);
|
||||
await appsProvider.notify(
|
||||
2,
|
||||
'Updates Available',
|
||||
message,
|
||||
'UPDATES_AVAILABLE',
|
||||
'Updates Available',
|
||||
'Notifies the user that updates are available for one or more Apps tracked by Obtainium');
|
||||
const String currentReleaseTag =
|
||||
'v0.1.8-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void bgTaskCallback() {
|
||||
// Background update checking process
|
||||
Workmanager().executeTask((task, taskName) async {
|
||||
var notificationsProvider = NotificationsProvider();
|
||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||
try {
|
||||
var appsProvider = AppsProvider();
|
||||
await notificationsProvider
|
||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps();
|
||||
List<App> updates = await appsProvider.checkUpdates();
|
||||
if (updates.isNotEmpty) {
|
||||
notificationsProvider.notify(UpdateNotification(updates),
|
||||
cancelExisting: true);
|
||||
}
|
||||
return Future.value(true);
|
||||
} catch (e) {
|
||||
notificationsProvider.notify(
|
||||
ErrorCheckingUpdatesNotification(e.toString()),
|
||||
cancelExisting: true);
|
||||
return Future.value(false);
|
||||
} finally {
|
||||
await notificationsProvider.cancel(checkingUpdatesNotification.id);
|
||||
}
|
||||
return Future.value(true);
|
||||
});
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
Workmanager().initialize(
|
||||
backgroundUpdateCheck,
|
||||
bgTaskCallback,
|
||||
);
|
||||
await Workmanager().cancelByUniqueName('update-apps-task');
|
||||
await Workmanager().registerPeriodicTask(
|
||||
'update-apps-task', 'backgroundUpdateCheck',
|
||||
frequency: const Duration(minutes: 15),
|
||||
initialDelay: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected));
|
||||
runApp(MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider())
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AppsProvider(
|
||||
shouldLoadApps: true,
|
||||
shouldCheckUpdatesAfterLoad: true,
|
||||
shouldDeleteAPKs: true)),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
Provider(create: (context) => NotificationsProvider())
|
||||
],
|
||||
child: const MyApp(),
|
||||
));
|
||||
@ -61,31 +74,37 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings();
|
||||
} else {
|
||||
// Register the background update task according to the user's setting
|
||||
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();
|
||||
if (isFirstRun) {
|
||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||
Permission.notification.request();
|
||||
appsProvider.saveApp(App(
|
||||
'imranr98_obtainium_${GitHub().host}',
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
currentReleaseTag,
|
||||
currentReleaseTag,
|
||||
[],
|
||||
0));
|
||||
}
|
||||
}
|
||||
|
||||
return DynamicColorBuilder(
|
||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||
// Initialize the settings provider (if needed) and perform first-run actions if needed
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings().then((_) {
|
||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||
if (isFirstRun) {
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
appsProvider
|
||||
.notify(
|
||||
3,
|
||||
'Permission Notification',
|
||||
'This is a transient notification used to trigger the Android 13 notification permission prompt',
|
||||
'PERMISSION_NOTIFICATION',
|
||||
'Permission Notifications',
|
||||
'A transient notification used to trigger the Android 13 notification permission prompt',
|
||||
important: false)
|
||||
.whenComplete(() {
|
||||
appsProvider.downloaderNotifications.cancel(3);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Decide on a colour/brightness scheme based on OS and user settings
|
||||
ColorScheme lightColorScheme;
|
||||
ColorScheme darkColorScheme;
|
||||
if (lightDynamic != null &&
|
||||
@ -98,7 +117,6 @@ class MyApp extends StatelessWidget {
|
||||
darkColorScheme = ColorScheme.fromSeed(
|
||||
seedColor: defaultThemeColour, brightness: Brightness.dark);
|
||||
}
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Obtainium',
|
||||
theme: ThemeData(
|
||||
@ -111,7 +129,8 @@ class MyApp extends StatelessWidget {
|
||||
useMaterial3: true,
|
||||
colorScheme: settingsProvider.theme == ThemeSettings.light
|
||||
? lightColorScheme
|
||||
: darkColorScheme),
|
||||
: darkColorScheme,
|
||||
fontFamily: 'Metropolis'),
|
||||
home: const HomePage());
|
||||
});
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/services/apps_provider.dart';
|
||||
import 'package:obtainium/services/source_service.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AddAppPage extends StatefulWidget {
|
||||
const AddAppPage({super.key});
|
||||
@ -18,73 +21,114 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
return Center(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'https://github.com/Author/Project',
|
||||
helperText: 'Enter the App source URL'),
|
||||
controller: urlInputController,
|
||||
validator: (value) {
|
||||
if (value == null ||
|
||||
value.isEmpty ||
|
||||
Uri.tryParse(value) == null) {
|
||||
return 'Please enter a supported source URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
SourceService()
|
||||
.getApp(urlInputController.value.text)
|
||||
.then((app) {
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
appsProvider.saveApp(app).then((_) {
|
||||
urlInputController.clear();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: app.id)));
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (gettingAppInfo) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
));
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'https://github.com/Author/Project',
|
||||
helperText: 'Enter the App source URL'),
|
||||
controller: urlInputController,
|
||||
validator: (value) {
|
||||
if (value == null ||
|
||||
value.isEmpty ||
|
||||
Uri.tryParse(value) == null) {
|
||||
return 'Please enter a supported source URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
sourceProvider
|
||||
.getApp(urlInputController.value.text)
|
||||
.then((app) {
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
appsProvider.saveApp(app).then((_) {
|
||||
urlInputController.clear();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: app.id)));
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
||||
// style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
e,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
]),
|
||||
if (gettingAppInfo)
|
||||
const LinearProgressIndicator()
|
||||
else
|
||||
Container(),
|
||||
],
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package: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:provider/provider.dart';
|
||||
|
||||
@ -16,6 +19,7 @@ class _AppPageState extends State<AppPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||
if (app?.app.installedVersion != null) {
|
||||
appsProvider.getUpdate(app!.app.id);
|
||||
@ -24,9 +28,58 @@ class _AppPageState extends State<AppPage> {
|
||||
appBar: AppBar(
|
||||
title: Text('${app?.app.author}/${app?.app.name}'),
|
||||
),
|
||||
body: WebView(
|
||||
initialUrl: app?.app.url,
|
||||
),
|
||||
body: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
app?.app.name ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
'By ${app?.app.author ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomSheet: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||
@ -44,11 +97,17 @@ class _AppPageState extends State<AppPage> {
|
||||
appsProvider
|
||||
.checkAppObjectForUpdate(
|
||||
app!.app)) &&
|
||||
app?.downloadProgress == null
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApp(
|
||||
app!.app.id);
|
||||
[app!.app.id],
|
||||
context).then((res) {
|
||||
if (res && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(app?.app.installedVersion == null
|
||||
@ -59,6 +118,7 @@ class _AppPageState extends State<AppPage> {
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@ -69,6 +129,7 @@ class _AppPageState extends State<AppPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.removeApp(app!.app.id)
|
||||
.then((_) {
|
||||
@ -81,6 +142,7 @@ class _AppPageState extends State<AppPage> {
|
||||
child: const Text('Remove')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
@ -89,8 +151,10 @@ class _AppPageState extends State<AppPage> {
|
||||
});
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).errorColor,
|
||||
surfaceTintColor: Theme.of(context).errorColor),
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.error,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
])),
|
||||
|
@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/services/apps_provider.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AppsPage extends StatefulWidget {
|
||||
@ -14,46 +16,67 @@ class _AppsPageState extends State<AppsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
appsProvider.getUpdates();
|
||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
||||
|
||||
return Center(
|
||||
child: appsProvider.loadingApps
|
||||
? const CircularProgressIndicator()
|
||||
: appsProvider.apps.isEmpty
|
||||
? Text(
|
||||
'No Apps',
|
||||
style: Theme.of(context).textTheme.headline4,
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: appsProvider.getUpdates,
|
||||
child: ListView(
|
||||
children: appsProvider.apps.values
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
title: Text('${e.app.author}/${e.app.name}'),
|
||||
subtitle:
|
||||
Text(e.app.installedVersion ?? 'Not Installed'),
|
||||
trailing: e.downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${e.downloadProgress!.toInt()}%')
|
||||
: (e.app.installedVersion != null &&
|
||||
e.app.installedVersion !=
|
||||
e.app.latestVersion
|
||||
? const Text('Update Available')
|
||||
: null),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: e.app.id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Scaffold(
|
||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
||||
? null
|
||||
: ElevatedButton.icon(
|
||||
onPressed: appsProvider.areDownloadsRunning()
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
context
|
||||
.read<SettingsProvider>()
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
appsProvider.downloadAndInstallLatestApp(
|
||||
existingUpdateAppIds, context);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.update),
|
||||
label: const Text('Update All')),
|
||||
body: Center(
|
||||
child: appsProvider.loadingApps
|
||||
? const CircularProgressIndicator()
|
||||
: appsProvider.apps.isEmpty
|
||||
? Text(
|
||||
'No Apps',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
return appsProvider.checkUpdates();
|
||||
},
|
||||
child: ListView(
|
||||
children: appsProvider.apps.values
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
title: Text('${e.app.author}/${e.app.name}'),
|
||||
subtitle: Text(
|
||||
e.app.installedVersion ?? 'Not Installed'),
|
||||
trailing: e.downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
||||
: (e.app.installedVersion != null &&
|
||||
e.app.installedVersion !=
|
||||
e.app.latestVersion
|
||||
? const Text('Update Available')
|
||||
: null),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: e.app.id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/add_app.dart';
|
||||
import 'package:obtainium/pages/apps.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
@ -20,22 +21,34 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Obtainium')),
|
||||
body: pages.elementAt(selectedIndex),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
},
|
||||
selectedIndex: selectedIndex,
|
||||
),
|
||||
);
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Obtainium')),
|
||||
body: pages.elementAt(selectedIndex),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings), label: 'Settings'),
|
||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
},
|
||||
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: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:url_launcher/url_launcher_string.dart';
|
||||
|
||||
@ -13,6 +17,7 @@ class SettingsPage extends StatefulWidget {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings();
|
||||
@ -66,17 +71,186 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
foregroundColor:
|
||||
MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
return Colors.grey;
|
||||
}),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
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:fluttertoast/fluttertoast.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum ThemeSettings { system, light, dark }
|
||||
@ -22,7 +26,6 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
set theme(ThemeSettings t) {
|
||||
print(t);
|
||||
prefs?.setInt('theme', t.index);
|
||||
notifyListeners();
|
||||
}
|
||||
@ -37,11 +40,42 @@ class SettingsProvider with ChangeNotifier {
|
||||
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;
|
||||
if (result) {
|
||||
prefs?.setBool('firstRun', false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> getInstallPermission() async {
|
||||
while (!(await Permission.requestInstallPackages.isGranted)) {
|
||||
// Explicit request as InstallPlugin request sometimes bugged
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Please allow Obtainium to install Apps',
|
||||
toastLength: Toast.LENGTH_LONG);
|
||||
if ((await Permission.requestInstallPackages.request()) ==
|
||||
PermissionStatus.granted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get showAppWebpage {
|
||||
return prefs?.getBool('showAppWebpage') ?? true;
|
||||
}
|
||||
|
||||
set showAppWebpage(bool show) {
|
||||
prefs?.setBool('showAppWebpage', show);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
371
lib/providers/source_provider.dart
Normal file
@ -0,0 +1,371 @@
|
||||
// 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]}";
|
||||
});
|
||||
}
|
||||
|
||||
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 'Not a valid URL';
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@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 = getLinksFromParsedHTML(
|
||||
parsedHtml,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin);
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw 'No APK found';
|
||||
}
|
||||
String getTag(String url) {
|
||||
List<String> parts = url.split('/');
|
||||
return parts[parts.length - 2];
|
||||
}
|
||||
|
||||
String latestTag = getTag(apkUrlList[0]);
|
||||
String? version = parsedHtml
|
||||
.querySelector('.octicon-tag')
|
||||
?.nextElementSibling
|
||||
?.innerHtml
|
||||
.trim();
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
}
|
||||
return APKDetails(version,
|
||||
apkUrlList.where((element) => getTag(element) == latestTag).toList());
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
}
|
||||
}
|
||||
|
||||
@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 'Not a valid URL';
|
||||
}
|
||||
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 'No APK found';
|
||||
}
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
}
|
||||
}
|
||||
|
||||
@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 'No APK found';
|
||||
}
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
}
|
||||
}
|
||||
|
||||
@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 'Not a valid URL';
|
||||
}
|
||||
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 'No APK found';
|
||||
}
|
||||
var version = latestReleaseDiv
|
||||
?.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
}
|
||||
}
|
||||
|
||||
@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 'Not a valid URL';
|
||||
}
|
||||
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 'Could not determine the latest release version';
|
||||
}
|
||||
return APKDetails(
|
||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
}
|
||||
}
|
||||
|
||||
@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,222 +0,0 @@
|
||||
// Provider that manages App-related state and provides functions to retrieve App info download/install Apps
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_fgbg/flutter_fgbg.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:obtainium/services/source_service.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:install_plugin_v2/install_plugin_v2.dart';
|
||||
|
||||
class AppInMemory {
|
||||
late App app;
|
||||
double? downloadProgress;
|
||||
|
||||
AppInMemory(this.app, this.downloadProgress);
|
||||
}
|
||||
|
||||
class AppsProvider with ChangeNotifier {
|
||||
// In memory App state (should always be kept in sync with local storage versions)
|
||||
Map<String, AppInMemory> apps = {};
|
||||
bool loadingApps = false;
|
||||
bool gettingUpdates = false;
|
||||
|
||||
// Notifications plugin for downloads
|
||||
FlutterLocalNotificationsPlugin downloaderNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Variables to keep track of the app foreground status (installs can't run in the background)
|
||||
bool isForeground = true;
|
||||
StreamSubscription<FGBGType>? foregroundSubscription;
|
||||
|
||||
AppsProvider({bool bg = false}) {
|
||||
initializeNotifs();
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundSubscription = FGBGEvents.stream.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
});
|
||||
loadApps();
|
||||
}
|
||||
|
||||
Future<void> initializeNotifs() async {
|
||||
// Initialize the notifications service
|
||||
await downloaderNotifications.initialize(const InitializationSettings(
|
||||
android: AndroidInitializationSettings('ic_notification')));
|
||||
}
|
||||
|
||||
Future<void> notify(int id, String title, String message, String channelCode,
|
||||
String channelName, String channelDescription,
|
||||
{bool important = true}) {
|
||||
return downloaderNotifications.show(
|
||||
id,
|
||||
title,
|
||||
message,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(channelCode, channelName,
|
||||
channelDescription: channelDescription,
|
||||
importance: important ? Importance.max : Importance.min,
|
||||
priority: important ? Priority.max : Priority.min,
|
||||
groupKey: 'dev.imranr.obtainium.$channelCode')));
|
||||
}
|
||||
|
||||
// Given a App (assumed valid), initiate an APK download (will trigger install callback when complete)
|
||||
Future<void> downloadAndInstallLatestApp(String appId) async {
|
||||
if (apps[appId] == null) {
|
||||
throw 'App not found';
|
||||
}
|
||||
StreamedResponse response =
|
||||
await Client().send(Request('GET', Uri.parse(apps[appId]!.app.apkUrl)));
|
||||
File downloadFile =
|
||||
File('${(await getExternalStorageDirectory())!.path}/$appId.apk');
|
||||
if (downloadFile.existsSync()) {
|
||||
downloadFile.deleteSync();
|
||||
}
|
||||
var length = response.contentLength;
|
||||
var received = 0;
|
||||
var sink = downloadFile.openWrite();
|
||||
|
||||
await response.stream.map((s) {
|
||||
received += s.length;
|
||||
apps[appId]!.downloadProgress =
|
||||
(length != null ? received / length * 100 : 30);
|
||||
notifyListeners();
|
||||
return s;
|
||||
}).pipe(sink);
|
||||
|
||||
await sink.close();
|
||||
apps[appId]!.downloadProgress = null;
|
||||
notifyListeners();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
downloadFile.deleteSync();
|
||||
throw response.reasonPhrase ?? 'Unknown Error';
|
||||
}
|
||||
|
||||
if (!isForeground) {
|
||||
await downloaderNotifications.cancel(1);
|
||||
await notify(
|
||||
1,
|
||||
'Complete App Installation',
|
||||
'Obtainium must be open to install Apps',
|
||||
'COMPLETE_INSTALL',
|
||||
'Complete App Installation',
|
||||
'Asks the user to return to Obtanium to finish installing an App');
|
||||
while (await FGBGEvents.stream.first != FGBGType.foreground) {
|
||||
// We need to wait for the App to come to the foreground to install it
|
||||
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
||||
// https://github.com/flutter/flutter/issues/13937
|
||||
}
|
||||
}
|
||||
|
||||
// Unfortunately this 'await' does not actually wait for the APK to finish installing
|
||||
// So we only know that the install prompt was shown, but the user could still cancel w/o us knowing
|
||||
// This also does not use the 'session-based' installer API, so background/silent updates are impossible
|
||||
await InstallPlugin.installApk(downloadFile.path, 'dev.imranr.obtainium');
|
||||
|
||||
apps[appId]!.app.installedVersion = apps[appId]!.app.latestVersion;
|
||||
saveApp(apps[appId]!.app);
|
||||
}
|
||||
|
||||
Future<Directory> getAppsDir() async {
|
||||
Directory appsDir = Directory(
|
||||
'${(await getExternalStorageDirectory())?.path as String}/app_data');
|
||||
if (!appsDir.existsSync()) {
|
||||
appsDir.createSync();
|
||||
}
|
||||
return appsDir;
|
||||
}
|
||||
|
||||
Future<void> loadApps() async {
|
||||
loadingApps = true;
|
||||
notifyListeners();
|
||||
List<FileSystemEntity> appFiles = (await getAppsDir())
|
||||
.listSync()
|
||||
.where((item) => item.path.toLowerCase().endsWith('.json'))
|
||||
.toList();
|
||||
apps.clear();
|
||||
for (int i = 0; i < appFiles.length; i++) {
|
||||
App app =
|
||||
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
|
||||
apps.putIfAbsent(app.id, () => AppInMemory(app, null));
|
||||
}
|
||||
loadingApps = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> saveApp(App app) async {
|
||||
File('${(await getAppsDir()).path}/${app.id}.json')
|
||||
.writeAsStringSync(jsonEncode(app.toJson()));
|
||||
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
|
||||
ifAbsent: () => AppInMemory(app, null));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeApp(String appId) async {
|
||||
File file = File('${(await getAppsDir()).path}/$appId.json');
|
||||
if (file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
if (apps.containsKey(appId)) {
|
||||
apps.remove(appId);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool checkAppObjectForUpdate(App app) {
|
||||
if (!apps.containsKey(app.id)) {
|
||||
throw 'App not found';
|
||||
}
|
||||
return app.latestVersion != apps[app.id]?.app.installedVersion;
|
||||
}
|
||||
|
||||
Future<App?> getUpdate(String appId) async {
|
||||
App? currentApp = apps[appId]!.app;
|
||||
App newApp = await SourceService().getApp(currentApp.url);
|
||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
await saveApp(newApp);
|
||||
return newApp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<App>> getUpdates() async {
|
||||
List<App> updates = [];
|
||||
if (!gettingUpdates) {
|
||||
gettingUpdates = true;
|
||||
|
||||
List<String> appIds = apps.keys.toList();
|
||||
for (int i = 0; i < appIds.length; i++) {
|
||||
App? newApp = await getUpdate(appIds[i]);
|
||||
if (newApp != null) {
|
||||
updates.add(newApp);
|
||||
}
|
||||
}
|
||||
gettingUpdates = false;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
Future<void> installUpdates() async {
|
||||
List<String> appIds = apps.keys.toList();
|
||||
for (int i = 0; i < appIds.length; i++) {
|
||||
App? app = apps[appIds[i]]!.app;
|
||||
if (app.installedVersion != app.latestVersion) {
|
||||
await downloadAndInstallLatestApp(app.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
IsolateNameServer.removePortNameMapping('downloader_send_port');
|
||||
foregroundSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
@ -1,159 +0,0 @@
|
||||
// Exposes functions related to interacting with App sources and retrieving App info
|
||||
// Stateless - not a provider
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:html/parser.dart';
|
||||
|
||||
// Sub-classes used in App Source
|
||||
|
||||
class AppNames {
|
||||
late String author;
|
||||
late String name;
|
||||
|
||||
AppNames(this.author, this.name);
|
||||
}
|
||||
|
||||
class APKDetails {
|
||||
late String version;
|
||||
late String downloadUrl;
|
||||
|
||||
APKDetails(this.version, this.downloadUrl);
|
||||
}
|
||||
|
||||
// App Source abstract class (diff. implementations for GitHub, GitLab, etc.)
|
||||
|
||||
abstract class AppSource {
|
||||
String standardizeURL(String url);
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
||||
AppNames getAppNames(String standardUrl);
|
||||
}
|
||||
|
||||
escapeRegEx(String s) {
|
||||
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
|
||||
return "\\${x[0]}";
|
||||
});
|
||||
}
|
||||
|
||||
// App class
|
||||
|
||||
class App {
|
||||
late String id;
|
||||
late String url;
|
||||
late String author;
|
||||
late String name;
|
||||
String? installedVersion;
|
||||
late String latestVersion;
|
||||
late String apkUrl;
|
||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
||||
this.latestVersion, this.apkUrl);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ID: $id URL: $url INSTALLED: $installedVersion LATEST: $latestVersion APK: $apkUrl';
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
json['id'] as String,
|
||||
json['url'] as String,
|
||||
json['author'] as String,
|
||||
json['name'] as String,
|
||||
json['installedVersion'] == null
|
||||
? null
|
||||
: json['installedVersion'] as String,
|
||||
json['latestVersion'] as String,
|
||||
json['apkUrl'] as String);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'author': author,
|
||||
'name': name,
|
||||
'installedVersion': installedVersion,
|
||||
'latestVersion': latestVersion,
|
||||
'apkUrl': apkUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Specific App Source classes
|
||||
|
||||
class GitHub implements AppSource {
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
String convertURL(String url, String replaceText) {
|
||||
int tempInd1 = url.indexOf('://') + 3;
|
||||
int tempInd2 = url.substring(tempInd1).indexOf('/') + tempInd1;
|
||||
return '${url.substring(0, tempInd1)}$replaceText${url.substring(tempInd2)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
var parsedHtml = parse(res.body);
|
||||
var apkUrlList = parsedHtml.querySelectorAll('a').where((element) {
|
||||
return RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/releases/download/*/(?!/).*.apk\$',
|
||||
caseSensitive: false)
|
||||
.hasMatch(element.attributes['href']!);
|
||||
}).toList();
|
||||
String? version = parsedHtml
|
||||
.querySelector('.octicon-tag')
|
||||
?.nextElementSibling
|
||||
?.innerHtml
|
||||
.trim();
|
||||
if (apkUrlList.isEmpty || version == null) {
|
||||
throw 'No APK found';
|
||||
}
|
||||
return APKDetails(
|
||||
version, '${standardUri.origin}${apkUrlList[0].attributes['href']!}');
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
String temp = standardUrl.substring(standardUrl.indexOf('://') + 3);
|
||||
List<String> names = temp.substring(temp.indexOf('/') + 1).split('/');
|
||||
return AppNames(names[0], names[1]);
|
||||
}
|
||||
}
|
||||
|
||||
class SourceService {
|
||||
// Add more source classes here so they are available via the service
|
||||
AppSource github = GitHub();
|
||||
AppSource getSource(String url) {
|
||||
if (url.toLowerCase().contains('://github.com')) {
|
||||
return github;
|
||||
}
|
||||
throw 'URL does not match a known source';
|
||||
}
|
||||
|
||||
Future<App> getApp(String url) async {
|
||||
if (url.toLowerCase().indexOf('http://') != 0 &&
|
||||
url.toLowerCase().indexOf('https://') != 0) {
|
||||
url = 'https://$url';
|
||||
}
|
||||
AppSource source = getSource(url);
|
||||
String standardUrl = source.standardizeURL(url);
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
||||
return App(
|
||||
'${names.author}_${names.name}',
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version,
|
||||
apk.downloadUrl);
|
||||
}
|
||||
}
|
108
pubspec.lock
@ -91,14 +91,56 @@ packages:
|
||||
name: dbus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.8"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
device_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dynamic_color
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.5.3"
|
||||
version: "1.5.4"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -119,7 +161,7 @@ packages:
|
||||
name: file
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.2"
|
||||
version: "6.1.4"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -152,7 +194,7 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.7.0"
|
||||
version: "9.9.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -177,6 +219,13 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fluttertoast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fluttertoast
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "8.0.9"
|
||||
html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -281,7 +330,7 @@ packages:
|
||||
name: path_provider_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.17"
|
||||
version: "2.0.20"
|
||||
path_provider_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -317,6 +366,41 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -393,7 +477,7 @@ packages:
|
||||
name: shared_preferences_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -454,7 +538,7 @@ packages:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.12"
|
||||
version: "0.4.13"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -531,7 +615,7 @@ packages:
|
||||
name: vector_math
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -545,14 +629,14 @@ packages:
|
||||
name: webview_flutter_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.9.5"
|
||||
version: "2.10.0"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
version: "1.9.3"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -580,7 +664,7 @@ packages:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0+1"
|
||||
version: "0.2.0+2"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -597,4 +681,4 @@ packages:
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
||||
flutter: ">=3.1.0-0.0.pre.1036"
|
||||
flutter: ">=3.3.0"
|
||||
|
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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.1.0+1
|
||||
version: 0.1.8+9 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
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.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
cupertino_icons: ^1.0.5
|
||||
path_provider: ^2.0.11
|
||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||
flutter_local_notifications: ^9.7.0
|
||||
flutter_local_notifications: ^9.9.1
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
workmanager: ^0.5.0
|
||||
dynamic_color: ^1.5.3
|
||||
dynamic_color: ^1.5.4
|
||||
install_plugin_v2: ^1.0.0 # Try replacing this
|
||||
html: ^0.15.0
|
||||
shared_preferences: ^2.0.15
|
||||
url_launcher: ^6.1.5
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^4.1.2
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
@ -60,13 +63,13 @@ dev_dependencies:
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_lints: ^2.0.1
|
||||
|
||||
flutter_icons:
|
||||
android: true
|
||||
image_path: "assets/icon.png"
|
||||
image_path: "assets/graphics/icon.png"
|
||||
adaptive_icon_background: "#FFFFFF"
|
||||
adaptive_icon_foreground: "assets/icon.png"
|
||||
adaptive_icon_foreground: "assets/graphics/icon.png"
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|