Compare commits
20 Commits
v0.1.4-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 |
18
README.md
@ -1,21 +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
|
|
||||||
- GitLab
|
|
||||||
|
|
||||||
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
||||||
|
|
||||||
|
Currently supported App sources:
|
||||||
|
- [GitHub](https://github.com/)
|
||||||
|
- [GitLab](https://gitlab.com/)
|
||||||
|
- [F-Droid](https://f-droid.org/)
|
||||||
|
- [Mullvad](https://mullvad.net/en/)
|
||||||
|
- [Signal](https://signal.org/)
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||||
|
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods are either unavailable (e.g. Mullvad), insufficient (e.g. GitHub RSS) or subject to rate limits (e.g. GitHub API).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> |
|
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
|
||||||
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| <img src="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> |
|
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.0 KiB |
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 |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
@ -9,18 +9,21 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v0.1.4-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v0.1.8-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void bgTaskCallback() {
|
void bgTaskCallback() {
|
||||||
// Background update checking process
|
// Background update checking process
|
||||||
Workmanager().executeTask((task, taskName) async {
|
Workmanager().executeTask((task, taskName) async {
|
||||||
var appsProvider = AppsProvider(bg: true);
|
|
||||||
var notificationsProvider = NotificationsProvider();
|
var notificationsProvider = NotificationsProvider();
|
||||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||||
try {
|
try {
|
||||||
|
var appsProvider = AppsProvider();
|
||||||
|
await notificationsProvider
|
||||||
|
.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||||
await appsProvider.loadApps();
|
await appsProvider.loadApps();
|
||||||
List<App> updates = await appsProvider.checkUpdates();
|
List<App> updates = await appsProvider.checkUpdates();
|
||||||
if (updates.isNotEmpty) {
|
if (updates.isNotEmpty) {
|
||||||
@ -41,16 +44,22 @@ void bgTaskCallback() {
|
|||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
|
||||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
);
|
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
}
|
||||||
Workmanager().initialize(
|
Workmanager().initialize(
|
||||||
bgTaskCallback,
|
bgTaskCallback,
|
||||||
);
|
);
|
||||||
runApp(MultiProvider(
|
runApp(MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => AppsProvider(
|
||||||
|
shouldLoadApps: true,
|
||||||
|
shouldCheckUpdatesAfterLoad: true,
|
||||||
|
shouldDeleteAPKs: true)),
|
||||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||||
Provider(create: (context) => NotificationsProvider())
|
Provider(create: (context) => NotificationsProvider())
|
||||||
],
|
],
|
||||||
@ -69,12 +78,7 @@ class MyApp extends StatelessWidget {
|
|||||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||||
|
|
||||||
if (settingsProvider.prefs == null) {
|
if (settingsProvider.prefs == null) {
|
||||||
settingsProvider.initializeSettings().then((value) {
|
settingsProvider.initializeSettings();
|
||||||
// Delete past downloads and check for updates every time the app is launched
|
|
||||||
// Only runs once as the settings are only initialized once (so not on every build)
|
|
||||||
appsProvider.deleteSavedAPKs();
|
|
||||||
appsProvider.checkUpdates();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Register the background update task according to the user's setting
|
// Register the background update task according to the user's setting
|
||||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
||||||
@ -87,12 +91,14 @@ class MyApp extends StatelessWidget {
|
|||||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||||
Permission.notification.request();
|
Permission.notification.request();
|
||||||
appsProvider.saveApp(App(
|
appsProvider.saveApp(App(
|
||||||
'imranr98_obtainium_github',
|
'imranr98_obtainium_${GitHub().host}',
|
||||||
'https://github.com/ImranR98/Obtainium',
|
'https://github.com/ImranR98/Obtainium',
|
||||||
'ImranR98',
|
'ImranR98',
|
||||||
'Obtainium',
|
'Obtainium',
|
||||||
currentReleaseTag,
|
currentReleaseTag,
|
||||||
currentReleaseTag, []));
|
currentReleaseTag,
|
||||||
|
[],
|
||||||
|
0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class AddAppPage extends StatefulWidget {
|
class AddAppPage extends StatefulWidget {
|
||||||
const AddAppPage({super.key});
|
const AddAppPage({super.key});
|
||||||
@ -19,77 +21,114 @@ class _AddAppPageState extends State<AddAppPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
return Center(
|
return Center(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const Spacer(),
|
children: [
|
||||||
Padding(
|
Container(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
Padding(
|
||||||
child: TextFormField(
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: const InputDecoration(
|
child: Column(
|
||||||
hintText: 'https://github.com/Author/Project',
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
helperText: 'Enter the App source URL'),
|
children: [
|
||||||
controller: urlInputController,
|
TextFormField(
|
||||||
validator: (value) {
|
decoration: const InputDecoration(
|
||||||
if (value == null ||
|
hintText: 'https://github.com/Author/Project',
|
||||||
value.isEmpty ||
|
helperText: 'Enter the App source URL'),
|
||||||
Uri.tryParse(value) == null) {
|
controller: urlInputController,
|
||||||
return 'Please enter a supported source URL';
|
validator: (value) {
|
||||||
}
|
if (value == null ||
|
||||||
return null;
|
value.isEmpty ||
|
||||||
},
|
Uri.tryParse(value) == null) {
|
||||||
)),
|
return 'Please enter a supported source URL';
|
||||||
Padding(
|
}
|
||||||
padding:
|
return null;
|
||||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
|
},
|
||||||
child: ElevatedButton(
|
),
|
||||||
onPressed: gettingAppInfo
|
Padding(
|
||||||
? null
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
: () {
|
child: ElevatedButton(
|
||||||
if (_formKey.currentState!.validate()) {
|
onPressed: gettingAppInfo
|
||||||
setState(() {
|
? null
|
||||||
gettingAppInfo = true;
|
: () {
|
||||||
});
|
HapticFeedback.mediumImpact();
|
||||||
sourceProvider()
|
if (_formKey.currentState!.validate()) {
|
||||||
.getApp(urlInputController.value.text)
|
setState(() {
|
||||||
.then((app) {
|
gettingAppInfo = true;
|
||||||
var appsProvider = context.read<AppsProvider>();
|
});
|
||||||
var settingsProvider =
|
sourceProvider
|
||||||
context.read<SettingsProvider>();
|
.getApp(urlInputController.value.text)
|
||||||
if (appsProvider.apps.containsKey(app.id)) {
|
.then((app) {
|
||||||
throw 'App already added';
|
var appsProvider =
|
||||||
}
|
context.read<AppsProvider>();
|
||||||
settingsProvider.getInstallPermission().then((_) {
|
var settingsProvider =
|
||||||
appsProvider.saveApp(app).then((_) {
|
context.read<SettingsProvider>();
|
||||||
urlInputController.clear();
|
if (appsProvider.apps.containsKey(app.id)) {
|
||||||
Navigator.push(
|
throw 'App already added';
|
||||||
context,
|
}
|
||||||
MaterialPageRoute(
|
settingsProvider
|
||||||
builder: (context) =>
|
.getInstallPermission()
|
||||||
AppPage(appId: app.id)));
|
.then((_) {
|
||||||
});
|
appsProvider.saveApp(app).then((_) {
|
||||||
});
|
urlInputController.clear();
|
||||||
}).catchError((e) {
|
Navigator.push(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
context,
|
||||||
SnackBar(content: Text(e.toString())),
|
MaterialPageRoute(
|
||||||
);
|
builder: (context) =>
|
||||||
}).whenComplete(() {
|
AppPage(appId: app.id)));
|
||||||
setState(() {
|
});
|
||||||
gettingAppInfo = false;
|
});
|
||||||
});
|
}).catchError((e) {
|
||||||
});
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
}
|
SnackBar(content: Text(e.toString())),
|
||||||
},
|
);
|
||||||
child: const Text('Add'),
|
}).whenComplete(() {
|
||||||
),
|
setState(() {
|
||||||
),
|
gettingAppInfo = false;
|
||||||
const Spacer(),
|
});
|
||||||
if (gettingAppInfo) const LinearProgressIndicator(),
|
});
|
||||||
],
|
}
|
||||||
),
|
},
|
||||||
));
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||||
|
const Text(
|
||||||
|
'Supported Sources:',
|
||||||
|
// style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
// style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
|
...sourceProvider
|
||||||
|
.getSourceHosts()
|
||||||
|
.map((e) => GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://$e',
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
e,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic),
|
||||||
|
)))
|
||||||
|
.toList()
|
||||||
|
]),
|
||||||
|
if (gettingAppInfo)
|
||||||
|
const LinearProgressIndicator()
|
||||||
|
else
|
||||||
|
Container(),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@ -16,6 +19,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var appsProvider = context.watch<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||||
if (app?.app.installedVersion != null) {
|
if (app?.app.installedVersion != null) {
|
||||||
appsProvider.getUpdate(app!.app.id);
|
appsProvider.getUpdate(app!.app.id);
|
||||||
@ -24,10 +28,58 @@ class _AppPageState extends State<AppPage> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('${app?.app.author}/${app?.app.name}'),
|
title: Text('${app?.app.author}/${app?.app.name}'),
|
||||||
),
|
),
|
||||||
body: WebView(
|
body: settingsProvider.showAppWebpage
|
||||||
initialUrl: app?.app.url,
|
? WebView(
|
||||||
javascriptMode: JavascriptMode.unrestricted,
|
initialUrl: app?.app.url,
|
||||||
),
|
javascriptMode: JavascriptMode.unrestricted,
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
app?.app.name ?? 'App',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'By ${app?.app.author ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (app?.app.url != null) {
|
||||||
|
launchUrlString(app?.app.url ?? '',
|
||||||
|
mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
app?.app.url ?? '',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontSize: 12),
|
||||||
|
)),
|
||||||
|
const SizedBox(
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
bottomSheet: Padding(
|
bottomSheet: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||||
@ -45,13 +97,16 @@ 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((_) {
|
context).then((res) {
|
||||||
Navigator.of(context).pop();
|
if (res && mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@ -63,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) {
|
||||||
@ -73,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((_) {
|
||||||
@ -85,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'))
|
||||||
@ -93,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,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/pages/app.dart';
|
import 'package:obtainium/pages/app.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
@ -21,11 +22,10 @@ class _AppsPageState extends State<AppsPage> {
|
|||||||
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
|
||||||
: () {
|
: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
context
|
context
|
||||||
.read<SettingsProvider>()
|
.read<SettingsProvider>()
|
||||||
.getInstallPermission()
|
.getInstallPermission()
|
||||||
@ -42,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(
|
||||||
@ -55,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(
|
||||||
appBar: AppBar(title: const Text('Obtainium')),
|
child: Scaffold(
|
||||||
body: pages.elementAt(selectedIndex),
|
appBar: AppBar(title: const Text('Obtainium')),
|
||||||
bottomNavigationBar: NavigationBar(
|
body: pages.elementAt(selectedIndex),
|
||||||
destinations: const [
|
bottomNavigationBar: NavigationBar(
|
||||||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
NavigationDestination(
|
||||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
icon: Icon(Icons.settings), label: 'Settings'),
|
||||||
],
|
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
||||||
onDestinationSelected: (int index) {
|
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
||||||
setState(() {
|
],
|
||||||
selectedIndex = index;
|
onDestinationSelected: (int index) {
|
||||||
});
|
HapticFeedback.lightImpact();
|
||||||
},
|
setState(() {
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex = index;
|
||||||
),
|
});
|
||||||
);
|
},
|
||||||
|
selectedIndex: selectedIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onWillPop: () async {
|
||||||
|
if (selectedIndex != 1) {
|
||||||
|
setState(() {
|
||||||
|
selectedIndex = 1;
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/settings_provider.dart';
|
import 'package:obtainium/providers/settings_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -109,7 +110,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 32,
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
@ -118,6 +133,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
onPressed: appsProvider.apps.isEmpty
|
onPressed: appsProvider.apps.isEmpty
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
appsProvider.exportApps().then((String path) {
|
appsProvider.exportApps().then((String path) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@ -125,9 +141,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text('Export Apps')),
|
child: const Text('Export App List')),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
@ -137,7 +154,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: const Text('Import Apps'),
|
title: const Text('Import App List'),
|
||||||
content: Column(children: [
|
content: Column(children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
||||||
@ -172,6 +189,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
if (formKey.currentState!
|
if (formKey.currentState!
|
||||||
.validate()) {
|
.validate()) {
|
||||||
appsProvider
|
appsProvider
|
||||||
@ -183,7 +207,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
.showSnackBar(
|
.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'$value Apps Imported')),
|
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||||
);
|
);
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
@ -198,16 +222,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Import')),
|
child: const Text('Import')),
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text('Cancel'))
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text('Import Apps'))
|
child: const Text('Import App List'))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@ -223,13 +242,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
launchUrlString(settingsProvider.sourceUrl,
|
launchUrlString(settingsProvider.sourceUrl,
|
||||||
mode: LaunchMode.externalApplication);
|
mode: LaunchMode.externalApplication);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.code),
|
icon: const Icon(Icons.code),
|
||||||
label: Text(
|
label: Text(
|
||||||
'Source',
|
'Source',
|
||||||
style: Theme.of(context).textTheme.caption,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -6,6 +6,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:obtainium/providers/notifications_provider.dart';
|
import 'package:obtainium/providers/notifications_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@ -38,14 +39,26 @@ class AppsProvider with ChangeNotifier {
|
|||||||
late Stream<FGBGType> foregroundStream;
|
late Stream<FGBGType> foregroundStream;
|
||||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
late StreamSubscription<FGBGType> foregroundSubscription;
|
||||||
|
|
||||||
AppsProvider({bool bg = false}) {
|
AppsProvider(
|
||||||
|
{bool shouldLoadApps = false,
|
||||||
|
bool shouldCheckUpdatesAfterLoad = false,
|
||||||
|
bool shouldDeleteAPKs = false}) {
|
||||||
// Subscribe to changes in the app foreground status
|
// Subscribe to changes in the app foreground status
|
||||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||||
foregroundSubscription = foregroundStream.listen((event) async {
|
foregroundSubscription = foregroundStream.listen((event) async {
|
||||||
isForeground = event == FGBGType.foreground;
|
isForeground = event == FGBGType.foreground;
|
||||||
if (isForeground) await loadApps();
|
if (isForeground) await loadApps();
|
||||||
});
|
});
|
||||||
loadApps();
|
if (shouldDeleteAPKs) {
|
||||||
|
deleteSavedAPKs();
|
||||||
|
}
|
||||||
|
if (shouldLoadApps) {
|
||||||
|
loadApps().then((_) {
|
||||||
|
if (shouldCheckUpdatesAfterLoad) {
|
||||||
|
checkUpdates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
||||||
@ -79,10 +92,14 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return ApkFile(appId, downloadFile);
|
return ApkFile(appId, downloadFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool areDownloadsRunning() => apps.values
|
||||||
|
.where((element) => element.downloadProgress != null)
|
||||||
|
.isNotEmpty;
|
||||||
|
|
||||||
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
||||||
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
||||||
// Returns upon successful download, regardless of installation result
|
// Returns upon successful download, regardless of installation result
|
||||||
Future<void> downloadAndInstallLatestApp(
|
Future<bool> downloadAndInstallLatestApp(
|
||||||
List<String> appIds, BuildContext context) async {
|
List<String> appIds, BuildContext context) async {
|
||||||
NotificationsProvider notificationsProvider =
|
NotificationsProvider notificationsProvider =
|
||||||
context.read<NotificationsProvider>();
|
context.read<NotificationsProvider>();
|
||||||
@ -91,37 +108,36 @@ class AppsProvider with ChangeNotifier {
|
|||||||
if (apps[id] == null) {
|
if (apps[id] == null) {
|
||||||
throw 'App not found';
|
throw 'App not found';
|
||||||
}
|
}
|
||||||
String apkUrl = apps[id]!.app.apkUrls.last;
|
// If the App has more than one APK, the user should pick one
|
||||||
|
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
||||||
if (apps[id]!.app.apkUrls.length > 1) {
|
if (apps[id]!.app.apkUrls.length > 1) {
|
||||||
await showDialog(
|
apkUrl = await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
return AlertDialog(
|
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
||||||
scrollable: true,
|
|
||||||
title: const Text('Pick an APK'),
|
|
||||||
content: Column(children: [
|
|
||||||
Text(
|
|
||||||
'${apps[id]!.app.name} has more than one package - pick one.'),
|
|
||||||
...apps[id]!.app.apkUrls.map((u) => ListTile(
|
|
||||||
title: Text(Uri.parse(u).pathSegments.last),
|
|
||||||
leading: Radio<String>(
|
|
||||||
value: u,
|
|
||||||
groupValue: apkUrl,
|
|
||||||
onChanged: (String? val) {
|
|
||||||
apkUrl = val!;
|
|
||||||
})))
|
|
||||||
]),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text('Continue'))
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
appsToInstall.putIfAbsent(id, () => apkUrl);
|
// If the picked APK comes from an origin different from the source, get user confirmation
|
||||||
|
if (apkUrl != null &&
|
||||||
|
!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
|
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
||||||
@ -131,6 +147,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
await notificationsProvider.notify(completeInstallationNotification,
|
await notificationsProvider.notify(completeInstallationNotification,
|
||||||
cancelExisting: true);
|
cancelExisting: true);
|
||||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
await FGBGEvents.stream.first == FGBGType.foreground;
|
||||||
|
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||||
// We need to wait for the App to come to the foreground to install it
|
// We need to wait for the App to come to the foreground to install it
|
||||||
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
||||||
// https://github.com/flutter/flutter/issues/13937
|
// https://github.com/flutter/flutter/issues/13937
|
||||||
@ -144,6 +161,8 @@ class AppsProvider with ChangeNotifier {
|
|||||||
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
||||||
await saveApp(apps[f.appId]!.app);
|
await saveApp(apps[f.appId]!.app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return downloadedFiles.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> getAppsDir() async {
|
Future<Directory> getAppsDir() async {
|
||||||
@ -209,9 +228,12 @@ class AppsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<App?> getUpdate(String appId) async {
|
Future<App?> getUpdate(String appId) async {
|
||||||
App? currentApp = apps[appId]!.app;
|
App? currentApp = apps[appId]!.app;
|
||||||
App newApp = await sourceProvider().getApp(currentApp.url);
|
App newApp = await SourceProvider().getApp(currentApp.url);
|
||||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
if (newApp.latestVersion != currentApp.latestVersion) {
|
||||||
newApp.installedVersion = currentApp.installedVersion;
|
newApp.installedVersion = currentApp.installedVersion;
|
||||||
|
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||||
|
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||||
|
}
|
||||||
await saveApp(newApp);
|
await saveApp(newApp);
|
||||||
return newApp;
|
return newApp;
|
||||||
}
|
}
|
||||||
@ -281,3 +303,90 @@ class AppsProvider with ChangeNotifier {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class APKPicker extends StatefulWidget {
|
||||||
|
const APKPicker({super.key, required this.app, this.initVal});
|
||||||
|
|
||||||
|
final App app;
|
||||||
|
final String? initVal;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<APKPicker> createState() => _APKPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _APKPickerState extends State<APKPicker> {
|
||||||
|
String? apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
apkUrl ??= widget.initVal;
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Pick an APK'),
|
||||||
|
content: Column(children: [
|
||||||
|
Text('${widget.app.name} has more than one package:'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
||||||
|
title: Text(Uri.parse(u).pathSegments.last),
|
||||||
|
value: u,
|
||||||
|
groupValue: apkUrl,
|
||||||
|
onChanged: (String? val) {
|
||||||
|
setState(() {
|
||||||
|
apkUrl = val;
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
Navigator.of(context).pop(apkUrl);
|
||||||
|
},
|
||||||
|
child: const Text('Continue'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class APKOriginWarningDialog extends StatefulWidget {
|
||||||
|
const APKOriginWarningDialog(
|
||||||
|
{super.key, required this.sourceUrl, required this.apkUrl});
|
||||||
|
|
||||||
|
final String sourceUrl;
|
||||||
|
final String apkUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: const Text('Warning'),
|
||||||
|
content: Text(
|
||||||
|
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop(null);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
},
|
||||||
|
child: const Text('Continue'))
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -69,4 +69,13 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get showAppWebpage {
|
||||||
|
return prefs?.getBool('showAppWebpage') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set showAppWebpage(bool show) {
|
||||||
|
prefs?.setBool('showAppWebpage', show);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,8 +29,9 @@ class App {
|
|||||||
String? installedVersion;
|
String? installedVersion;
|
||||||
late String latestVersion;
|
late String latestVersion;
|
||||||
List<String> apkUrls = [];
|
List<String> apkUrls = [];
|
||||||
|
late int preferredApkIndex;
|
||||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
||||||
this.latestVersion, this.apkUrls);
|
this.latestVersion, this.apkUrls, this.preferredApkIndex);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@ -38,15 +39,19 @@ class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||||
json['id'] as String,
|
json['id'] as String,
|
||||||
json['url'] as String,
|
json['url'] as String,
|
||||||
json['author'] as String,
|
json['author'] as String,
|
||||||
json['name'] as String,
|
json['name'] as String,
|
||||||
json['installedVersion'] == null
|
json['installedVersion'] == null
|
||||||
? null
|
? null
|
||||||
: json['installedVersion'] as String,
|
: json['installedVersion'] as String,
|
||||||
json['latestVersion'] as String,
|
json['latestVersion'] as String,
|
||||||
List<String>.from(jsonDecode(json['apkUrls'])));
|
List<String>.from(jsonDecode(json['apkUrls'])),
|
||||||
|
json['preferredApkIndex'] == null
|
||||||
|
? 0
|
||||||
|
: json['preferredApkIndex'] as int,
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -56,6 +61,7 @@ class App {
|
|||||||
'installedVersion': installedVersion,
|
'installedVersion': installedVersion,
|
||||||
'latestVersion': latestVersion,
|
'latestVersion': latestVersion,
|
||||||
'apkUrls': jsonEncode(apkUrls),
|
'apkUrls': jsonEncode(apkUrls),
|
||||||
|
'preferredApkIndex': preferredApkIndex
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +83,7 @@ List<String> getLinksFromParsedHTML(
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
abstract class AppSource {
|
abstract class AppSource {
|
||||||
late String sourceId;
|
late String host;
|
||||||
String standardizeURL(String url);
|
String standardizeURL(String url);
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
||||||
AppNames getAppNames(String standardUrl);
|
AppNames getAppNames(String standardUrl);
|
||||||
@ -85,11 +91,11 @@ abstract class AppSource {
|
|||||||
|
|
||||||
class GitHub implements AppSource {
|
class GitHub implements AppSource {
|
||||||
@override
|
@override
|
||||||
String sourceId = 'github';
|
late String host = 'github.com';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String standardizeURL(String url) {
|
String standardizeURL(String url) {
|
||||||
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw 'Not a valid URL';
|
throw 'Not a valid URL';
|
||||||
@ -99,7 +105,6 @@ class GitHub implements AppSource {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||||
// The GitHub RSS feed does not contain asset download details, so we use web scraping (avoid API due to rate limits)
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var standardUri = Uri.parse(standardUrl);
|
var standardUri = Uri.parse(standardUrl);
|
||||||
@ -144,11 +149,11 @@ class GitHub implements AppSource {
|
|||||||
|
|
||||||
class GitLab implements AppSource {
|
class GitLab implements AppSource {
|
||||||
@override
|
@override
|
||||||
String sourceId = 'gitlab';
|
late String host = 'gitlab.com';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String standardizeURL(String url) {
|
String standardizeURL(String url) {
|
||||||
RegExp standardUrlRegEx = RegExp(r'^https?://gitlab.com/[^/]*/[^/]*');
|
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||||
if (match == null) {
|
if (match == null) {
|
||||||
throw 'Not a valid URL';
|
throw 'Not a valid URL';
|
||||||
@ -158,25 +163,31 @@ class GitLab implements AppSource {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||||
// GitLab provides an RSS feed with all the details we need
|
|
||||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
var standardUri = Uri.parse(standardUrl);
|
var standardUri = Uri.parse(standardUrl);
|
||||||
var parsedHtml = parse(res.body);
|
var parsedHtml = parse(res.body);
|
||||||
var entry = parsedHtml.querySelector('entry');
|
var entry = parsedHtml.querySelector('entry');
|
||||||
var entryContent =
|
var entryContent =
|
||||||
parse(parseFragment(entry!.querySelector('content')!.innerHtml).text);
|
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||||
var apkUrlList = getLinksFromParsedHTML(
|
var apkUrlList = [
|
||||||
entryContent,
|
...getLinksFromParsedHTML(
|
||||||
RegExp(
|
entryContent,
|
||||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
RegExp(
|
||||||
caseSensitive: false),
|
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||||
standardUri.origin);
|
caseSensitive: false),
|
||||||
|
standardUri.origin),
|
||||||
|
// GitLab releases may contain links to externally hosted APKs
|
||||||
|
...getLinksFromParsedHTML(entryContent,
|
||||||
|
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
||||||
|
.where((element) => Uri.parse(element).host != '')
|
||||||
|
.toList()
|
||||||
|
];
|
||||||
if (apkUrlList.isEmpty) {
|
if (apkUrlList.isEmpty) {
|
||||||
throw 'No APK found';
|
throw 'No APK found';
|
||||||
}
|
}
|
||||||
|
|
||||||
var entryId = entry.querySelector('id')?.innerHtml;
|
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||||
var version =
|
var version =
|
||||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||||
if (version == null) {
|
if (version == null) {
|
||||||
@ -195,15 +206,142 @@ class GitLab implements AppSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class sourceProvider {
|
class Signal implements AppSource {
|
||||||
|
@override
|
||||||
|
late String host = 'signal.org';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String standardizeURL(String url) {
|
||||||
|
return 'https://$host';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||||
|
Response res =
|
||||||
|
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
var json = jsonDecode(res.body);
|
||||||
|
String? apkUrl = json['url'];
|
||||||
|
if (apkUrl == null) {
|
||||||
|
throw '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
|
// Add more source classes here so they are available via the service
|
||||||
AppSource getSource(String url) {
|
AppSource getSource(String url) {
|
||||||
if (url.toLowerCase().contains('://github.com')) {
|
AppSource? source;
|
||||||
return GitHub();
|
for (var s in sources) {
|
||||||
} else if (url.toLowerCase().contains('://gitlab.com')) {
|
if (url.toLowerCase().contains('://${s.host}')) {
|
||||||
return GitLab();
|
source = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw 'URL does not match a known source';
|
if (source == null) {
|
||||||
|
throw 'URL does not match a known source';
|
||||||
|
}
|
||||||
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<App> getApp(String url) async {
|
Future<App> getApp(String url) async {
|
||||||
@ -219,12 +357,15 @@ class sourceProvider {
|
|||||||
AppNames names = source.getAppNames(standardUrl);
|
AppNames names = source.getAppNames(standardUrl);
|
||||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
||||||
return App(
|
return App(
|
||||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.sourceId}',
|
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
||||||
standardUrl,
|
standardUrl,
|
||||||
names.author[0].toUpperCase() + names.author.substring(1),
|
names.author[0].toUpperCase() + names.author.substring(1),
|
||||||
names.name[0].toUpperCase() + names.name.substring(1),
|
names.name[0].toUpperCase() + names.name.substring(1),
|
||||||
null,
|
null,
|
||||||
apk.version,
|
apk.version,
|
||||||
apk.apkUrls);
|
apk.apkUrls,
|
||||||
|
apk.apkUrls.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||||
}
|
}
|
||||||
|
58
pubspec.lock
@ -92,13 +92,55 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.8"
|
version: "0.7.8"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
device_info_plus_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_linux
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_macos
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_web
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
device_info_plus_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_windows
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "4.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:
|
||||||
@ -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.8.0+1"
|
version: "9.9.1"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -435,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:
|
||||||
@ -496,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:
|
||||||
@ -573,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:
|
||||||
@ -587,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.2"
|
version: "1.9.3"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -639,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"
|
||||||
|
15
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.4+5 # When changing this, update the tag in main() accordingly
|
version: 0.1.8+9 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||||
@ -35,21 +35,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.8.0+1
|
flutter_local_notifications: ^9.9.1
|
||||||
provider: ^6.0.3
|
provider: ^6.0.3
|
||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
webview_flutter: ^3.0.4
|
webview_flutter: ^3.0.4
|
||||||
workmanager: ^0.5.0
|
workmanager: ^0.5.0
|
||||||
dynamic_color: ^1.5.3
|
dynamic_color: ^1.5.4
|
||||||
install_plugin_v2: ^1.0.0 # Try replacing this
|
install_plugin_v2: ^1.0.0 # Try replacing this
|
||||||
html: ^0.15.0
|
html: ^0.15.0
|
||||||
shared_preferences: ^2.0.15
|
shared_preferences: ^2.0.15
|
||||||
url_launcher: ^6.1.5
|
url_launcher: ^6.1.5
|
||||||
permission_handler: ^10.0.0
|
permission_handler: ^10.0.0
|
||||||
fluttertoast: ^8.0.9
|
fluttertoast: ^8.0.9
|
||||||
|
device_info_plus: ^4.1.2
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
@ -62,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
|
||||||
|