Compare commits

...

14 Commits

Author SHA1 Message Date
77e1768f3b Bugfix 2022-09-25 11:46:25 -04:00
da9e5aed5e Apps page UI improvements 2022-09-25 11:32:57 -04:00
136628c9e6 Removed an unused import 2022-09-25 03:22:22 -04:00
a916167be3 Added basic SourceForge support 2022-09-25 03:21:57 -04:00
420cf487d4 Basic custom App name support (only when adding) 2022-09-25 02:39:41 -04:00
12855370b0 Merge pull request #31 from ImranR98/apps-list-improvements
Added
- Multi select on the Apps page with share, delete, and install actions - #23 
- (Related to above) Ability to filter and update all out of date Apps - #27 
- Notifying users to return to the App to complete installs is less buggy thanks to the new installer plugin - #24
2022-09-25 02:01:51 -04:00
33fed1cb2f Reduced dependece on fgbg thanks to new install plugin 2022-09-25 01:56:24 -04:00
33238b56a9 Added IconButton tootlips 2022-09-25 01:43:51 -04:00
428c208de4 Added share option, saveApp -> saveApps 2022-09-25 01:41:50 -04:00
9a4b0301be Updated version, standardized quotes, deleted test_page 2022-09-25 00:21:41 -04:00
f58d26524c Done w/ filter and multi select stuff 2022-09-25 00:12:02 -04:00
45e5544c5b Added apps list selection (actions incomplete) 2022-09-24 21:10:29 -04:00
0a9373e65a More work on silent updates (not working in BG) 2022-09-24 18:43:05 -04:00
b65c6e1d41 Bugfixes + started work on silent udates 2022-09-24 15:00:47 -04:00
18 changed files with 862 additions and 399 deletions

View File

@ -30,6 +30,16 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path path="Android/data/dev.imranr.obtainium/" name="files_root" />
<external-path path="." name="external_storage_root" />
</paths>

View File

@ -21,9 +21,9 @@ class GitHub implements AppSource {
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
var includePrereleases =
additionalData.isNotEmpty && additionalData[0] == "true";
additionalData.isNotEmpty && additionalData[0] == 'true';
var fallbackToOlderReleases =
additionalData.length >= 2 && additionalData[1] == "true";
additionalData.length >= 2 && additionalData[1] == 'true';
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
? additionalData[2]
: null;
@ -92,14 +92,14 @@ class GitHub implements AppSource {
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [
[GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)],
[GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)],
[
GeneratedFormItem(
label: "Fallback to older releases", type: FormItemType.bool)
label: 'Fallback to older releases', type: FormItemType.bool)
],
[
GeneratedFormItem(
label: "Filter Release Titles by Regular Expression",
label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string,
required: false,
additionalValidators: [
@ -110,7 +110,7 @@ class GitHub implements AppSource {
try {
RegExp(value);
} catch (e) {
return "Invalid regular expression";
return 'Invalid regular expression';
}
return null;
}
@ -119,5 +119,5 @@ class GitHub implements AppSource {
];
@override
List<String> additionalDataDefaults = ["true", "true", ""];
List<String> additionalDataDefaults = ['true', 'true', ''];
}

View File

@ -0,0 +1,68 @@
import 'package:html/parser.dart';
import 'package:http/http.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/providers/source_provider.dart';
class SourceForge implements AppSource {
@override
late String host = 'sourceforge.net';
@override
String standardizeURL(String url) {
RegExp standardUrlRegEx = RegExp('^https?://$host/projects/[^/]+');
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
if (match == null) {
throw notValidURL(runtimeType.toString());
}
return url.substring(0, match.end);
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async {
Response res = await get(Uri.parse('$standardUrl/rss?path=/'));
if (res.statusCode == 200) {
var parsedHtml = parse(res.body);
var allDownloadLinks =
parsedHtml.querySelectorAll('guid').map((e) => e.innerHtml).toList();
getVersion(String url) {
try {
var tokens = url.split('/');
return tokens[tokens.length - 3];
} catch (e) {
return null;
}
}
String? version = getVersion(allDownloadLinks[0]);
if (version == null) {
throw couldNotFindLatestVersion;
}
var apkUrlListAllReleases = allDownloadLinks
.where((element) => element.toLowerCase().endsWith('.apk/download'))
.toList();
var apkUrlList =
apkUrlListAllReleases // This can be used skipped for fallback support later
.where((element) => getVersion(element) == version)
.toList();
if (apkUrlList.isEmpty) {
throw noAPKFound;
}
return APKDetails(version, apkUrlList);
} else {
throw couldNotFindReleases;
}
}
@override
AppNames getAppNames(String standardUrl) {
return AppNames(runtimeType.toString(),
standardUrl.substring(standardUrl.lastIndexOf('/') + 1));
}
@override
List<List<GeneratedFormItem>> additionalDataFormItems = [];
@override
List<String> additionalDataDefaults = [];
}

View File

@ -12,7 +12,7 @@ class GeneratedFormItem {
late List<String? Function(String? value)> additionalValidators;
GeneratedFormItem(
{this.label = "Input",
{this.label = 'Input',
this.type = FormItemType.string,
this.required = true,
this.max = 1,
@ -69,7 +69,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
.map((row) => row.map((e) {
return j < widget.defaultValues.length
? widget.defaultValues[j++]
: "";
: '';
}).toList())
.toList();
@ -89,7 +89,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
});
},
decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? " *" : "")),
helperText: e.value.label + (e.value.required ? ' *' : '')),
minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) {
@ -122,10 +122,10 @@ class _GeneratedFormState extends State<GeneratedForm> {
children: [
Text(widget.items[r][e].label),
Switch(
value: values[r][e] == "true",
value: values[r][e] == 'true',
onChanged: (value) {
setState(() {
values[r][e] = value ? "true" : "";
values[r][e] = value ? 'true' : '';
someValueChanged();
});
})

View File

@ -7,11 +7,15 @@ class GeneratedFormModal extends StatefulWidget {
{super.key,
required this.title,
required this.items,
required this.defaultValues});
required this.defaultValues,
this.initValid = false,
this.message = ''});
final String title;
final String message;
final List<List<GeneratedFormItem>> items;
final List<String> defaultValues;
final bool initValid;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
@ -21,20 +25,34 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
List<String> values = [];
bool valid = false;
@override
void initState() {
super.initState();
valid = widget.initValid;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
scrollable: true,
title: Text(widget.title),
content: GeneratedForm(
items: widget.items,
onValueChanges: (values, valid) {
setState(() {
this.values = values;
this.valid = valid;
});
},
defaultValues: widget.defaultValues),
content:
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (widget.message.isNotEmpty) Text(widget.message),
if (widget.message.isNotEmpty)
const SizedBox(
height: 16,
),
GeneratedForm(
items: widget.items,
onValueChanges: (values, valid) {
setState(() {
this.values = values;
this.valid = valid;
});
},
defaultValues: widget.defaultValues)
]),
actions: [
TextButton(
onPressed: () {

View File

@ -13,7 +13,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag =
'v0.3.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
'v0.4.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point')
void bgTaskCallback() {
@ -26,9 +26,23 @@ void bgTaskCallback() {
await notificationsProvider
.cancel(ErrorCheckingUpdatesNotification('').id);
await appsProvider.loadApps();
List<App> updates = await appsProvider.checkUpdates();
if (updates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(updates),
// List<String> existingUpdateIds = // TODO: Uncomment this and below when it works
// appsProvider.getExistingUpdates(installedOnly: true);
List<App> newUpdates = await appsProvider.checkUpdates();
// List<String> silentlyUpdated = await appsProvider
// .downloadAndInstallLatestApp(
// [...newUpdates.map((e) => e.id), ...existingUpdateIds], null);
// if (silentlyUpdated.isNotEmpty) {
// newUpdates
// .where((element) => !silentlyUpdated.contains(element.id))
// .toList();
// notificationsProvider.notify(
// SilentUpdateNotification(
// silentlyUpdated.map((e) => appsProvider.apps[e]!.app).toList()),
// cancelExisting: true);
// }
if (newUpdates.isNotEmpty) {
notificationsProvider.notify(UpdateNotification(newUpdates),
cancelExisting: true);
}
return Future.value(true);
@ -95,16 +109,18 @@ class MyApp extends StatelessWidget {
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,
["true"]));
appsProvider.saveApps([
App(
'imranr98_obtainium_${GitHub().host}',
'https://github.com/ImranR98/Obtainium',
'ImranR98',
'Obtainium',
currentReleaseTag,
currentReleaseTag,
[],
0,
['true'])
]);
}
}

View File

@ -19,9 +19,10 @@ class AddAppPage extends StatefulWidget {
class _AddAppPageState extends State<AddAppPage> {
bool gettingAppInfo = false;
String userInput = "";
String userInput = '';
AppSource? pickedSource;
List<String> additionalData = [];
String customName = '';
bool validAdditionalData = true;
@override
@ -44,19 +45,19 @@ class _AddAppPageState extends State<AddAppPage> {
items: [
[
GeneratedFormItem(
label: "App Source Url",
label: 'App Source Url',
additionalValidators: [
(value) {
try {
sourceProvider
.getSource(value ?? "")
.getSource(value ?? '')
.standardizeURL(
makeUrlHttps(
value ?? ""));
value ?? ''));
} catch (e) {
return e is String
? e
: "Error";
: 'Error';
}
return null;
}
@ -79,6 +80,9 @@ class _AddAppPageState extends State<AddAppPage> {
.doesSourceHaveRequiredAdditionalData(
source)
: true;
if (source == null) {
customName = '';
}
}
});
},
@ -100,7 +104,8 @@ class _AddAppPageState extends State<AddAppPage> {
});
sourceProvider
.getApp(pickedSource!, userInput,
additionalData)
additionalData,
customName: customName)
.then((app) {
var appsProvider =
context.read<AppsProvider>();
@ -113,7 +118,8 @@ class _AddAppPageState extends State<AddAppPage> {
settingsProvider
.getInstallPermission()
.then((_) {
appsProvider.saveApp(app).then((_) {
appsProvider
.saveApps([app]).then((_) {
Navigator.push(
context,
MaterialPageRoute(
@ -136,8 +142,7 @@ class _AddAppPageState extends State<AddAppPage> {
child: const Text('Add'))
],
),
if (pickedSource != null &&
(pickedSource!.additionalDataFormItems.isNotEmpty))
if (pickedSource != null)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -152,16 +157,38 @@ class _AddAppPageState extends State<AddAppPage> {
const SizedBox(
height: 16,
),
GeneratedForm(
items: pickedSource!.additionalDataFormItems,
onValueChanges: (values, valid) {
setState(() {
additionalData = values;
validAdditionalData = valid;
});
},
defaultValues:
pickedSource!.additionalDataDefaults)
if (pickedSource!
.additionalDataFormItems.isNotEmpty)
GeneratedForm(
items: pickedSource!.additionalDataFormItems,
onValueChanges: (values, valid) {
setState(() {
additionalData = values;
validAdditionalData = valid;
});
},
defaultValues:
pickedSource!.additionalDataDefaults),
if (pickedSource!
.additionalDataFormItems.isNotEmpty)
const SizedBox(
height: 8,
),
if (pickedSource != null)
GeneratedForm(
items: [
[
GeneratedFormItem(
label: 'Custom App Name',
required: false)
]
],
onValueChanges: (values, valid) {
setState(() {
customName = values[0];
});
},
defaultValues: [customName])
],
)
else

View File

@ -25,8 +25,12 @@ class _AppPageState extends State<AppPage> {
var sourceProvider = SourceProvider();
AppInMemory? app = appsProvider.apps[widget.appId];
var source = app != null ? sourceProvider.getSource(app.app.url) : null;
if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.app.id);
if (!appsProvider.areDownloadsRunning() && app != null) {
appsProvider.getUpdate(app.app.id).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
}
return Scaffold(
appBar: settingsProvider.showAppWebpage ? AppBar() : null,
@ -96,104 +100,113 @@ class _AppPageState extends State<AppPage> {
children: [
if (app?.app.installedVersion != app?.app.latestVersion)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
updatedApp.latestVersion;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Installed'))
],
);
});
},
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp
.installedVersion =
updatedApp
.latestVersion;
appsProvider.saveApps(
[updatedApp]);
}
Navigator.of(context)
.pop();
},
child: const Text(
'Yes, Mark as Installed'))
],
);
});
},
tooltip: 'Mark as Installed',
icon: const Icon(Icons.done))
else
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('App Not Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp.installedVersion =
null;
appsProvider
.saveApp(updatedApp);
}
Navigator.of(context).pop();
},
child: const Text(
'Yes, Mark as Not Installed'))
],
);
});
},
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text(
'App Not Installed?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop();
},
child: const Text('No')),
TextButton(
onPressed: () {
HapticFeedback
.selectionClick();
var updatedApp = app?.app;
if (updatedApp != null) {
updatedApp
.installedVersion =
null;
appsProvider.saveApps(
[updatedApp]);
}
Navigator.of(context)
.pop();
},
child: const Text(
'Yes, Mark as Not Installed'))
],
);
});
},
tooltip: 'Mark as Not Installed',
icon: const Icon(Icons.no_cell_outlined)),
if (source != null &&
source.additionalDataFormItems.isNotEmpty)
IconButton(
onPressed: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source.additionalDataFormItems,
defaultValues: app != null
? app.app.additionalData
: source.additionalDataDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
sourceProvider
.getApp(source, changedApp.url,
changedApp.additionalData)
.then((finalChangedApp) {
appsProvider.saveApp(finalChangedApp);
}).catchError((e) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
}
});
},
onPressed: app?.downloadProgress != null
? null
: () {
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Additional Options',
items: source
.additionalDataFormItems,
defaultValues: app != null
? app.app.additionalData
: source
.additionalDataDefaults);
}).then((values) {
if (app != null && values != null) {
var changedApp = app.app;
changedApp.additionalData = values;
appsProvider.saveApps([changedApp]);
}
});
},
tooltip: 'Additional Options',
icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0),
Expanded(
@ -209,7 +222,7 @@ class _AppPageState extends State<AppPage> {
.downloadAndInstallLatestApp(
[app!.app.id],
context).then((res) {
if (res && mounted) {
if (res.isNotEmpty && mounted) {
Navigator.of(context).pop();
}
});
@ -235,9 +248,8 @@ class _AppPageState extends State<AppPage> {
onPressed: () {
HapticFeedback
.selectionClick();
appsProvider
.removeApp(app!.app.id)
.then((_) {
appsProvider.removeApps(
[app!.app.id]).then((_) {
int count = 0;
Navigator.of(context)
.popUntil((_) =>

View File

@ -7,28 +7,71 @@ import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
class AppsPage extends StatefulWidget {
const AppsPage({super.key});
@override
State<AppsPage> createState() => _AppsPageState();
State<AppsPage> createState() => AppsPageState();
}
class _AppsPageState extends State<AppsPage> {
class AppsPageState extends State<AppsPage> {
AppsFilter? filter;
var updatesOnlyFilter =
AppsFilter(includeUptodate: false, includeNonInstalled: false);
Set<String> selectedIds = {};
clearSelected() {
if (selectedIds.isNotEmpty) {
setState(() {
selectedIds.clear();
});
return true;
}
return false;
}
selectThese(List<String> appIds) {
if (selectedIds.isEmpty) {
setState(() {
for (var a in appIds) {
selectedIds.add(a);
}
});
}
}
@override
Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates();
var sortedApps = appsProvider.apps.values.toList();
var currentFilterIsUpdatesOnly =
filter?.isIdenticalTo(updatesOnlyFilter) ?? false;
selectedIds = selectedIds
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
.toSet();
toggleAppSelected(String appId) {
setState(() {
if (selectedIds.contains(appId)) {
selectedIds.remove(appId);
} else {
selectedIds.add(appId);
}
});
}
if (filter != null) {
sortedApps = sortedApps.where((app) {
if (app.app.installedVersion == app.app.latestVersion &&
filter!.onlyNonLatest) {
!(filter!.includeUptodate)) {
return false;
}
if (app.app.installedVersion == null &&
!(filter!.includeNonInstalled)) {
return false;
}
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
@ -73,137 +116,320 @@ class _AppsPageState extends State<AppsPage> {
sortedApps = sortedApps.reversed.toList();
}
var existingUpdateIdsAllOrSelected = appsProvider
.getExistingUpdates(installedOnly: true)
.where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
.toList();
var newInstallIdsAllOrSelected = appsProvider
.getExistingUpdates(nonInstalledOnly: true)
.where((element) => selectedIds.isEmpty
? sortedApps.where((a) => a.app.id == element).isNotEmpty
: selectedIds.contains(element))
.toList();
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
floatingActionButton:
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
existingUpdateAppIds.isEmpty || filter != null
? const SizedBox()
: ElevatedButton.icon(
onPressed: appsProvider.areDownloadsRunning()
? null
: () {
HapticFeedback.heavyImpact();
settingsProvider.getInstallPermission().then((_) {
appsProvider.downloadAndInstallLatestApp(
existingUpdateAppIds, context);
});
},
icon: const Icon(Icons.install_mobile_outlined),
label: const Text('Install All')),
const SizedBox(
width: 16,
),
appsProvider.apps.isEmpty
? const SizedBox()
: ElevatedButton.icon(
onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Filter Apps',
items: [
[
GeneratedFormItem(
label: "App Name", required: false),
GeneratedFormItem(
label: "Author", required: false)
],
[
GeneratedFormItem(
label: "Ignore Up-to-Date Apps",
type: FormItemType.bool)
]
],
defaultValues: filter == null
? []
: [
filter!.nameFilter,
filter!.authorFilter,
filter!.onlyNonLatest ? 'true' : ''
]);
}).then((values) {
if (values != null &&
values
.where((element) => element.isNotEmpty)
.isNotEmpty) {
setState(() {
filter = AppsFilter(
nameFilter: values[0],
authorFilter: values[1],
onlyNonLatest: values[2] == "true");
});
} else {
setState(() {
filter = null;
});
}
});
},
label: Text(filter == null ? 'Search' : 'Modify Search'),
icon: Icon(
filter == null ? Icons.search : Icons.manage_search)),
]),
body: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
},
child: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Apps'),
if (appsProvider.loadingApps || sortedApps.isEmpty)
SliverFillRemaining(
child: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: Text(
appsProvider.apps.isEmpty
? 'No Apps'
: 'No Search Results',
style:
Theme.of(context).textTheme.headlineMedium,
))),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
title: Text(sortedApps[index].app.name),
subtitle: Text('By ${sortedApps[index].app.author}'),
trailing: sortedApps[index].downloadProgress != null
? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
: (sortedApps[index].app.installedVersion != null &&
sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion
? const Text('Update Available')
: Text(sortedApps[index].app.installedVersion ??
'Not Installed')),
onTap: () {
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
},
child: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Apps'),
if (appsProvider.loadingApps || sortedApps.isEmpty)
SliverFillRemaining(
child: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: Text(
appsProvider.apps.isEmpty
? 'No Apps'
: 'No Apps for Filter',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
))),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
selectedTileColor:
Theme.of(context).colorScheme.primary.withOpacity(0.1),
selected: selectedIds.contains(sortedApps[index].app.id),
onLongPress: () {
toggleAppSelected(sortedApps[index].app.id);
},
title: Text(sortedApps[index].app.name),
subtitle: Text('By ${sortedApps[index].app.author}'),
trailing: sortedApps[index].downloadProgress != null
? Text(
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
: (sortedApps[index].app.installedVersion != null &&
sortedApps[index].app.installedVersion !=
sortedApps[index].app.latestVersion
? const Text('Update Available')
: Text(sortedApps[index].app.installedVersion ??
'Not Installed')),
onTap: () {
if (selectedIds.isNotEmpty) {
toggleAppSelected(sortedApps[index].app.id);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
AppPage(appId: sortedApps[index].app.id)),
);
},
);
}, childCount: sortedApps.length))
])));
}
},
);
}, childCount: sortedApps.length))
])),
persistentFooterButtons: [
Row(
children: [
IconButton(
onPressed: () {
selectedIds.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList())
: clearSelected();
},
icon: Icon(
selectedIds.isEmpty
? Icons.select_all_outlined
: Icons.deselect_outlined,
color: Theme.of(context).colorScheme.primary,
),
tooltip: selectedIds.isEmpty
? 'Select All'
: 'Deselect ${selectedIds.length.toString()}'),
const VerticalDivider(),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
selectedIds.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Remove Selected Apps?',
items: const [],
defaultValues: const [],
initValid: true,
message:
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
);
}).then((values) {
if (values != null) {
appsProvider.removeApps(selectedIds.toList());
}
});
},
tooltip: 'Remove Selected Apps',
icon: const Icon(Icons.delete_outline_outlined),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() ||
(existingUpdateIdsAllOrSelected.isEmpty &&
newInstallIdsAllOrSelected.isEmpty)
? null
: () {
HapticFeedback.heavyImpact();
List<List<GeneratedFormItem>> formInputs = [];
if (existingUpdateIdsAllOrSelected.isNotEmpty &&
newInstallIdsAllOrSelected.isNotEmpty) {
formInputs.add([
GeneratedFormItem(
label:
'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool)
]);
formInputs.add([
GeneratedFormItem(
label:
'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}',
type: FormItemType.bool)
]);
}
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title:
'Install${selectedIds.isEmpty ? ' ' : ' Selected '}Apps?',
message:
'${existingUpdateIdsAllOrSelected.length} update${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'} and ${newInstallIdsAllOrSelected.length} new install${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}.',
items: formInputs,
defaultValues: const ['true', 'true'],
initValid: true,
);
}).then((values) {
if (values != null) {
bool shouldInstallUpdates =
values.length < 2 || values[0] == 'true';
bool shouldInstallNew =
values.length < 2 || values[1] == 'true';
settingsProvider
.getInstallPermission()
.then((_) {
List<String> toInstall = [];
if (shouldInstallUpdates) {
toInstall
.addAll(existingUpdateIdsAllOrSelected);
}
if (shouldInstallNew) {
toInstall
.addAll(newInstallIdsAllOrSelected);
}
appsProvider.downloadAndInstallLatestApp(
toInstall, context);
});
}
});
},
tooltip:
'Install/Update${selectedIds.isEmpty ? ' ' : ' Selected '}Apps',
icon: const Icon(
Icons.file_download_outlined,
)),
selectedIds.isEmpty
? const SizedBox()
: IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
String urls = '';
for (var id in selectedIds) {
urls += '${appsProvider.apps[id]!.app.url}\n';
}
urls = urls.substring(0, urls.length - 1);
Share.share(urls,
subject: 'Selected App URLs from Obtainium');
},
tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share),
),
],
)),
const VerticalDivider(),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() {
if (currentFilterIsUpdatesOnly) {
filter = null;
} else {
filter = updatesOnlyFilter;
}
});
},
tooltip: currentFilterIsUpdatesOnly
? 'Remove Out-of-Date App Filter'
: 'Show Out-of-Date Apps Only',
icon: Icon(
currentFilterIsUpdatesOnly
? Icons.update_disabled_rounded
: Icons.update_rounded,
color: Theme.of(context).colorScheme.primary,
),
),
appsProvider.apps.isEmpty
? const SizedBox()
: TextButton.icon(
label: Text(
filter == null ? 'Filter' : 'Filter *',
style: TextStyle(
fontWeight: filter == null
? FontWeight.normal
: FontWeight.bold),
),
onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Filter Apps',
items: [
[
GeneratedFormItem(
label: 'App Name', required: false),
GeneratedFormItem(
label: 'Author', required: false)
],
[
GeneratedFormItem(
label: 'Up to Date Apps',
type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Non-Installed Apps',
type: FormItemType.bool)
]
],
defaultValues: filter == null
? AppsFilter().toValuesArray()
: filter!.toValuesArray());
}).then((values) {
if (values != null) {
setState(() {
filter = AppsFilter.fromValuesArray(values);
if (AppsFilter().isIdenticalTo(filter!)) {
filter = null;
}
});
}
});
},
icon: const Icon(Icons.filter_list_rounded))
],
),
],
);
}
}
class AppsFilter {
late String nameFilter;
late String authorFilter;
late bool onlyNonLatest;
late bool includeUptodate;
late bool includeNonInstalled;
AppsFilter(
{this.nameFilter = "",
this.authorFilter = "",
this.onlyNonLatest = false});
{this.nameFilter = '',
this.authorFilter = '',
this.includeUptodate = true,
this.includeNonInstalled = true});
List<String> toValuesArray() {
return [
nameFilter,
authorFilter,
includeUptodate ? 'true' : '',
includeNonInstalled ? 'true' : ''
];
}
AppsFilter.fromValuesArray(List<String> values) {
nameFilter = values[0];
authorFilter = values[1];
includeUptodate = values[2] == 'true';
includeNonInstalled = values[3] == 'true';
}
bool isIdenticalTo(AppsFilter other) =>
authorFilter.trim() == other.authorFilter.trim() &&
nameFilter.trim() == other.nameFilter.trim() &&
includeUptodate == other.includeUptodate &&
includeNonInstalled == other.includeNonInstalled;
}

View File

@ -25,7 +25,8 @@ class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = [];
List<NavigationPageItem> pages = [
NavigationPageItem('Apps', Icons.apps, const AppsPage()),
NavigationPageItem(
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
@ -88,7 +89,10 @@ class _HomePageState extends State<HomePage> {
});
return false;
}
return true;
return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
// return !appsPageKey.currentState?.clearSelected();
});
}
}

View File

@ -47,7 +47,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'});
} else {
await appsProvider.saveApp(app);
await appsProvider.saveApps([app]);
}
}
List<List<String>> errors =

View File

@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:obtainium/components/generated_form.dart';
class TestPage extends StatefulWidget {
const TestPage({super.key});
@override
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> {
List<String?>? sourceSpecificData;
bool valid = false;
List<List<GeneratedFormItem>> sourceSpecificInputs = [
[GeneratedFormItem(label: 'Test Item 1')],
[
GeneratedFormItem(label: 'Test Item 2', required: false),
GeneratedFormItem(label: 'Test Item 3')
],
[GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)]
];
List<String> defaultInputValues = ["ABC"];
void onSourceSpecificDataChanges(
List<String?> valuesFromForm, bool formValid) {
setState(() {
sourceSpecificData = valuesFromForm;
valid = formValid;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Test Page')),
backgroundColor: Theme.of(context).colorScheme.surface,
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: [
GeneratedForm(
items: sourceSpecificInputs,
onValueChanges: onSourceSpecificDataChanges,
defaultValues: defaultInputValues,
),
...(sourceSpecificData != null
? (sourceSpecificData as List<String?>)
.map((e) => Text(e ?? ""))
: [Container()])
])));
}
}

View File

@ -5,6 +5,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:obtainium/providers/notifications_provider.dart';
@ -13,7 +14,7 @@ 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';
import 'package:flutter_install_app/flutter_install_app.dart';
class AppInMemory {
late App app;
@ -96,30 +97,65 @@ class AppsProvider with ChangeNotifier {
.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 {
Future<bool> canInstallSilently(App app) async {
// TODO: This is unreliable - try to get from OS in the future
var osInfo = await DeviceInfoPlugin().androidInfo;
return app.installedVersion != null &&
osInfo.version.sdkInt! >= 30 &&
osInfo.version.release!.compareTo('12') >= 0;
}
Future<void> askUserToReturnToForeground(BuildContext context,
{bool waitForFG = false}) async {
NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>();
if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true);
if (waitForFG) {
await FGBGEvents.stream.first == FGBGType.foreground;
await notificationsProvider.cancel(completeInstallationNotification.id);
}
}
}
// 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
// If appropriate criteria are met, the update (never a fresh install) happens silently in the background
// But even then, we don't know if it actually succeeded
Future<void> installApk(ApkFile file) async {
await AppInstaller.installApk(file.file.path, actionRequired: false);
apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion;
await saveApps([apps[file.appId]!.app]);
}
// Given a list of AppIds, uses stored info about the apps to download APKs and install them
// If the APKs can be installed silently, they are
// If no BuildContext is provided, apps that require user interaction are ignored
// If user input is needed and the App is in the background, a notification is sent to get the user's attention
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
Future<List<String>> downloadAndInstallLatestApp(
List<String> appIds, BuildContext? context) async {
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
// If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
if (apps[id]!.app.apkUrls.length > 1) {
if (apps[id]!.app.apkUrls.length > 1 && context != null) {
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 the picked APK comes from an origin different from the source, get user confirmation (if context provided)
if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin &&
context != null) {
if (await showDialog(
context: context,
builder: (BuildContext ctx) {
@ -134,35 +170,45 @@ class AppsProvider with ChangeNotifier {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) {
apps[id]!.app.preferredApkIndex = urlInd;
await saveApp(apps[id]!.app);
await saveApps([apps[id]!.app]);
}
if (context != null ||
(await canInstallSilently(apps[id]!.app) &&
apps[id]!.app.apkUrls.length == 1)) {
appsToInstall.putIfAbsent(id, () => apkUrl!);
}
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
List<ApkFile> silentUpdates = [];
List<ApkFile> regularInstalls = [];
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);
bool willBeSilent = await canInstallSilently(apps[f.appId]!.app);
if (willBeSilent) {
silentUpdates.add(f);
} else {
regularInstalls.add(f);
}
}
return downloadedFiles.isNotEmpty;
for (var u in silentUpdates) {
await installApk(u);
}
if (context != null) {
if (regularInstalls.isNotEmpty) {
// ignore: use_build_context_synchronously
await askUserToReturnToForeground(context);
}
for (var i in regularInstalls) {
await installApk(i);
}
}
return downloadedFiles.map((e) => e.appId).toList();
}
Future<Directory> getAppsDir() async {
@ -200,23 +246,30 @@ class AppsProvider with ChangeNotifier {
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));
Future<void> saveApps(List<App> apps) async {
for (var app in apps) {
File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson()));
this.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();
Future<void> removeApps(List<String> appIds) async {
for (var appId in appIds) {
File file = File('${(await getAppsDir()).path}/$appId.json');
if (file.existsSync()) {
file.deleteSync();
}
if (apps.containsKey(appId)) {
apps.remove(appId);
}
}
if (apps.containsKey(appId)) {
apps.remove(appId);
if (appIds.isNotEmpty) {
notifyListeners();
}
notifyListeners();
}
bool checkAppObjectForUpdate(App app) {
@ -238,7 +291,7 @@ class AppsProvider with ChangeNotifier {
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex;
}
await saveApp(newApp);
await saveApps([newApp]);
return newApp;
}
return null;
@ -261,13 +314,20 @@ class AppsProvider with ChangeNotifier {
return updates;
}
List<String> getExistingUpdates() {
List<String> getExistingUpdates(
{bool installedOnly = false, bool nonInstalledOnly = false}) {
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);
if (app.installedVersion != app.latestVersion &&
(!installedOnly || !nonInstalledOnly)) {
if ((app.installedVersion == null &&
(nonInstalledOnly || !installedOnly) ||
(app.installedVersion != null &&
(installedOnly || !nonInstalledOnly)))) {
updateAppIds.add(app.id);
}
}
}
return updateAppIds;
@ -295,7 +355,7 @@ class AppsProvider with ChangeNotifier {
for (App a in importedApps) {
a.installedVersion =
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
await saveApp(a);
await saveApps([a]);
}
notifyListeners();
return importedApps.length;

View File

@ -33,6 +33,22 @@ class UpdateNotification extends ObtainiumNotification {
}
}
class SilentUpdateNotification extends ObtainiumNotification {
SilentUpdateNotification(List<App> updates)
: super(
3,
'Apps Updated',
'',
'APPS_UPDATED',
'Apps Updated',
'Notifies the user that updates to one or more Apps were applied in the background',
Importance.defaultImportance) {
message = updates.length == 1
? '${updates[0].name} was updated to ${updates[0].latestVersion}.'
: '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.';
}
}
class ErrorCheckingUpdatesNotification extends ObtainiumNotification {
ErrorCheckingUpdatesNotification(String error)
: super(

View File

@ -10,6 +10,7 @@ import 'package:obtainium/app_sources/gitlab.dart';
import 'package:obtainium/app_sources/izzyondroid.dart';
import 'package:obtainium/app_sources/mullvad.dart';
import 'package:obtainium/app_sources/signal.dart';
import 'package:obtainium/app_sources/sourceforge.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/mass_app_sources/githubstars.dart';
@ -85,7 +86,7 @@ class App {
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}";
return '\\${x[0]}';
});
}
@ -144,7 +145,8 @@ class SourceProvider {
FDroid(),
IzzyOnDroid(),
Mullvad(),
Signal()
Signal(),
SourceForge()
];
// Add more mass source classes here so they are available via the service
@ -176,8 +178,8 @@ class SourceProvider {
return false;
}
Future<App> getApp(
AppSource source, String url, List<String> additionalData) async {
Future<App> getApp(AppSource source, String url, List<String> additionalData,
{String customName = ''}) async {
String standardUrl = source.standardizeURL(makeUrlHttps(url));
AppNames names = source.getAppNames(standardUrl);
APKDetails apk =
@ -186,7 +188,9 @@ class SourceProvider {
'${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),
customName.trim().isNotEmpty
? customName
: names.name[0].toUpperCase() + names.name.substring(1),
null,
apk.version,
apk.apkUrls,

View File

@ -7,7 +7,7 @@ packages:
name: animations
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
version: "2.0.5"
archive:
dependency: transitive
description:
@ -175,7 +175,7 @@ packages:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0"
version: "5.2.0"
flutter:
dependency: "direct main"
description: flutter
@ -188,6 +188,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
flutter_install_app:
dependency: "direct main"
description:
name: flutter_install_app
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -208,7 +215,7 @@ packages:
name: flutter_local_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
version: "11.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
@ -275,13 +282,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.0"
install_plugin_v2:
dependency: "direct main"
description:
name: install_plugin_v2
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
js:
dependency: transitive
description:
@ -295,7 +295,7 @@ packages:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.6.0"
version: "4.7.0"
lints:
dependency: transitive
description:
@ -324,6 +324,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
nested:
dependency: transitive
description:
@ -393,7 +400,7 @@ packages:
name: permission_handler
url: "https://pub.dartlang.org"
source: hosted
version: "10.0.0"
version: "10.0.1"
permission_handler_android:
dependency: transitive
description:
@ -414,7 +421,7 @@ packages:
name: permission_handler_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.0"
version: "3.7.1"
permission_handler_windows:
dependency: transitive
description:
@ -457,6 +464,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.3"
share_plus:
dependency: "direct main"
description:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.4.0"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences:
dependency: "direct main"
description:
@ -538,7 +587,7 @@ packages:
name: stream_channel
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@ -566,7 +615,7 @@ packages:
name: timezone
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.0"
version: "0.9.0"
typed_data:
dependency: transitive
description:
@ -636,7 +685,7 @@ packages:
name: vector_math
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
version: "2.1.4"
webview_flutter:
dependency: "direct main"
description:
@ -650,7 +699,7 @@ packages:
name: webview_flutter_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.10.1"
version: "2.10.2"
webview_flutter_platform_interface:
dependency: transitive
description:

View File

@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 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.3.2+18 # When changing this, update the tag in main() accordingly
version: 0.4.1+20 # When changing this, update the tag in main() accordingly
environment:
sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -38,13 +38,12 @@ dependencies:
cupertino_icons: ^1.0.5
path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this
flutter_local_notifications: ^10.0.0
flutter_local_notifications: ^11.0.1
provider: ^6.0.3
http: ^0.13.5
webview_flutter: ^3.0.4
workmanager: ^0.5.0
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
@ -53,6 +52,8 @@ dependencies:
device_info_plus: ^4.1.2
file_picker: ^5.1.0
animations: ^2.0.4
flutter_install_app: ^1.3.0
share_plus: ^4.4.0
dev_dependencies: