diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ccee49f..1062122 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,16 @@ + + + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..670a26f --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lib/pages/app.dart b/lib/pages/app.dart index 7cf0fc4..49cacbd 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -25,8 +25,12 @@ class _AppPageState extends State { 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()) { + 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,112 @@ class _AppPageState extends State { 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.saveApp( + 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.saveApp( + 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.saveApp(changedApp); + } + }); + }, icon: const Icon(Icons.settings)), const SizedBox(width: 16.0), Expanded( diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 64ff3a3..ca35304 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -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,21 +97,55 @@ 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 + Future canInstallSilently(App app) async { + var osInfo = await DeviceInfoPlugin().androidInfo; + return app.installedVersion != null && + osInfo.version.sdkInt! >= 30 && + osInfo.version.release!.compareTo('12') >= 0; + } + + Future askUserToReturnToForeground(BuildContext context) async { + NotificationsProvider notificationsProvider = + context.read(); + 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 + // 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 installApk(ApkFile file) async { + await AppInstaller.installApk(file.file.path, actionRequired: false); + apps[file.appId]!.app.installedVersion = + apps[file.appId]!.app.latestVersion; + await saveApp(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 user input is needed and the App is in the background, a notification is sent to get the user's attention // Returns upon successful download, regardless of installation result Future downloadAndInstallLatestApp( List appIds, BuildContext context) async { - NotificationsProvider notificationsProvider = - context.read(); Map appsToInstall = {}; for (var id in appIds) { if (apps[id] == null) { throw 'App not found'; } + // If the App has more than one APK, the user should pick one String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; if (apps[id]!.app.apkUrls.length > 1) { + // ignore: use_build_context_synchronously + await askUserToReturnToForeground(context); apkUrl = await showDialog( context: context, builder: (BuildContext ctx) { @@ -120,6 +155,8 @@ class AppsProvider with ChangeNotifier { // If the picked APK comes from an origin different from the source, get user confirmation if (apkUrl != null && Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) { + // ignore: use_build_context_synchronously + await askUserToReturnToForeground(context); if (await showDialog( context: context, builder: (BuildContext ctx) { @@ -143,23 +180,25 @@ class AppsProvider with ChangeNotifier { List 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 + List silentUpdates = []; + List regularInstalls = []; + for (var f in downloadedFiles) { + bool willBeSilent = await canInstallSilently(apps[f.appId]!.app); + if (willBeSilent) { + silentUpdates.add(f); + } else { + regularInstalls.add(f); + } } - // Unfortunately this 'await' does not actually wait for the APK to finish installing - // So we only know that the install prompt was shown, but the user could still cancel w/o us knowing - // This also does not use the 'session-based' installer API, so background/silent updates are impossible - for (var f in downloadedFiles) { - await InstallPlugin.installApk(f.file.path, 'dev.imranr.obtainium'); - apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion; - await saveApp(apps[f.appId]!.app); + for (var u in silentUpdates) { + await installApk(u); + } + + for (var i in regularInstalls) { + // ignore: use_build_context_synchronously + await askUserToReturnToForeground(context); + await installApk(i); } return downloadedFiles.isNotEmpty; diff --git a/pubspec.lock b/pubspec.lock index 64faa17..0255f80 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 3d1c191..919ce6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,6 @@ dependencies: 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,7 @@ dependencies: device_info_plus: ^4.1.2 file_picker: ^5.1.0 animations: ^2.0.4 + flutter_install_app: ^1.3.0 dev_dependencies: