Began moving to different download/install process

Previous download plgin was buggy, overcomplicated, and unmaintained.
Began switch to a different approach (not done - installs fail).
Also, the GitHub API is no longer used (rate limit) - web scraping instead.
This commit is contained in:
Imran Remtulla
2022-08-23 13:20:40 -04:00
parent cfcfc9b534
commit c265cd01d9
7 changed files with 129 additions and 196 deletions

View File

@@ -30,15 +30,6 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<provider
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
android:authorities="${applicationId}.flutter_downloader.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

View File

@@ -17,16 +17,16 @@ 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>();
App? app = appsProvider.apps[widget.appId]; AppInMemory? app = appsProvider.apps[widget.appId];
if (app?.installedVersion != null) { if (app?.app.installedVersion != null) {
appsProvider.getUpdate(app!.id); appsProvider.getUpdate(app!.app.id);
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('${app?.author}/${app?.name}'), title: Text('${app!.app.author}/${app.app.name}'),
), ),
body: WebView( body: WebView(
initialUrl: app?.url, initialUrl: app.app.url,
), ),
bottomSheet: Column( bottomSheet: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -39,21 +39,21 @@ class _AppPageState extends State<AppPage> {
children: [ children: [
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.installedVersion == null || onPressed: (app.app.installedVersion == null ||
appsProvider appsProvider.checkAppObjectForUpdate(
.checkAppObjectForUpdate(app!)) && app.app)) &&
app?.currentDownloadId == null app.downloadProgress == null
? () { ? () {
appsProvider appsProvider.downloadAndInstallLatestApp(
.backgroundDownloadAndInstallApp(app!); app.app.id);
} }
: null, : null,
child: Text(app?.installedVersion == null child: Text(app.app.installedVersion == null
? 'Install' ? 'Install'
: 'Update'))), : 'Update'))),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
ElevatedButton( ElevatedButton(
onPressed: app?.currentDownloadId != null onPressed: app.downloadProgress != null
? null ? null
: () { : () {
showDialog( showDialog(
@@ -62,12 +62,12 @@ class _AppPageState extends State<AppPage> {
return AlertDialog( return AlertDialog(
title: const Text('Remove App?'), title: const Text('Remove App?'),
content: Text( content: Text(
'This will remove \'${app?.name}\' from Obtainium.${app?.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'), 'This will remove \'${app.app.name}\' from Obtainium.${app.app.installedVersion != null ? '\n\nNote that while Obtainium will no longer track its updates, the App will remain installed.' : ''}'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
appsProvider appsProvider
.removeApp(app!.id) .removeApp(app.app.id)
.then((_) { .then((_) {
int count = 0; int count = 0;
Navigator.of(context).popUntil( Navigator.of(context).popUntil(
@@ -90,7 +90,8 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove'), child: const Text('Remove'),
), ),
])), ])),
if (app?.currentDownloadId != null) const LinearProgressIndicator() if (app.downloadProgress != null)
LinearProgressIndicator(value: app.downloadProgress)
], ],
), ),
); );

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/services/apps_provider.dart'; import 'package:obtainium/services/apps_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -31,18 +30,23 @@ class _AppsPageState extends State<AppsPage> {
children: appsProvider.apps.values children: appsProvider.apps.values
.map( .map(
(e) => ListTile( (e) => ListTile(
title: Text('${e.author}/${e.name}'), title: Text('${e.app.author}/${e.app.name}'),
subtitle: subtitle:
Text(e.installedVersion ?? 'Not Installed'), Text(e.app.installedVersion ?? 'Not Installed'),
trailing: e.installedVersion != null && trailing: e.downloadProgress != null
e.installedVersion != e.latestVersion ? Text(
'Downloading - ${e.downloadProgress!.toInt()}%')
: (e.app.installedVersion != null &&
e.app.installedVersion !=
e.app.latestVersion
? const Text('Update Available') ? const Text('Update Available')
: null, : null),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => AppPage(appId: e.id)), builder: (context) =>
AppPage(appId: e.app.id)),
); );
}, },
), ),

View File

@@ -3,64 +3,45 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:obtainium/services/source_service.dart'; import 'package:obtainium/services/source_service.dart';
import 'package:http/http.dart';
import 'package:install_plugin_v2/install_plugin_v2.dart';
class AppInMemory {
late App app;
double? downloadProgress;
AppInMemory(this.app, this.downloadProgress);
}
class AppsProvider with ChangeNotifier { class AppsProvider with ChangeNotifier {
// In memory App state (should always be kept in sync with local storage versions) // In memory App state (should always be kept in sync with local storage versions)
Map<String, App> apps = {}; Map<String, AppInMemory> apps = {};
bool loadingApps = false; bool loadingApps = false;
bool gettingUpdates = false; bool gettingUpdates = false;
AppsProvider({bool bg = false}) {
initializeNotifs();
loadApps().then((_) {
clearDownloadStates();
});
if (!bg) {
initializeDownloader();
}
}
// Notifications plugin for downloads // Notifications plugin for downloads
FlutterLocalNotificationsPlugin downloaderNotifications = FlutterLocalNotificationsPlugin downloaderNotifications =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
// Port for FlutterDownloader background/foreground communication
final ReceivePort _port = ReceivePort();
// Variables to keep track of the app foreground status (installs can't run in the background) // Variables to keep track of the app foreground status (installs can't run in the background)
bool isForeground = true; bool isForeground = true;
StreamSubscription<FGBGType>? foregroundSubscription; StreamSubscription<FGBGType>? foregroundSubscription;
// Setup the FlutterDownloader plugin (call only once) AppsProvider({bool bg = false}) {
Future<void> initializeDownloader() async { initializeNotifs();
// Make sure FlutterDownloader can be used
await FlutterDownloader.initialize();
// Set up the status update callback for FlutterDownloader
FlutterDownloader.registerCallback(downloadCallbackBackground);
// The actual callback is in the background isolate
// So setup a port to pass the data to a foreground callback
IsolateNameServer.registerPortWithName(
_port.sendPort, 'downloader_send_port');
_port.listen((dynamic data) {
String id = data[0];
DownloadTaskStatus status = data[1];
int progress = data[2];
downloadCallbackForeground(id, status, progress);
});
// Subscribe to changes in the app foreground status // Subscribe to changes in the app foreground status
foregroundSubscription = FGBGEvents.stream.listen((event) async { foregroundSubscription = FGBGEvents.stream.listen((event) async {
isForeground = event == FGBGType.foreground; isForeground = event == FGBGType.foreground;
if (isForeground) await loadApps(); if (isForeground) await loadApps();
}); });
loadApps();
} }
Future<void> initializeNotifs() async { Future<void> initializeNotifs() async {
@@ -69,15 +50,6 @@ class AppsProvider with ChangeNotifier {
android: AndroidInitializationSettings('ic_notification'))); android: AndroidInitializationSettings('ic_notification')));
} }
// Callback that receives FlutterDownloader status and forwards to a foreground function
@pragma('vm:entry-point')
static void downloadCallbackBackground(
String id, DownloadTaskStatus status, int progress) {
final SendPort? send =
IsolateNameServer.lookupPortByName('downloader_send_port');
send!.send([id, status, progress]);
}
Future<void> notify(int id, String title, String message, String channelCode, Future<void> notify(int id, String title, String message, String channelCode,
String channelName, String channelDescription) { String channelName, String channelDescription) {
return downloaderNotifications.show( return downloaderNotifications.show(
@@ -92,63 +64,44 @@ class AppsProvider with ChangeNotifier {
groupKey: 'dev.imranr.obtainium.$channelCode'))); groupKey: 'dev.imranr.obtainium.$channelCode')));
} }
// Foreground function to act on FlutterDownloader status updates (install downloaded APK) // Given a App (assumed valid), initiate an APK download (will trigger install callback when complete)
void downloadCallbackForeground( Future<void> downloadAndInstallLatestApp(String appId) async {
String id, DownloadTaskStatus status, int progress) async { if (apps[appId] == null) {
if (status == DownloadTaskStatus.complete) { throw 'App not found';
// Wait for app to come to the foreground if not already, and notify the user
while (!isForeground) {
await notify(
1,
'Complete App Installation',
'Obtainium must be open to install Apps',
'COMPLETE_INSTALL',
'Complete App Installation',
'Asks the user to return to Obtanium to finish installing an App');
if (await FGBGEvents.stream.first == FGBGType.foreground) {
break;
}
}
// Install the App (and remove warning notification if any)
FlutterDownloader.open(taskId: id);
downloaderNotifications.cancel(1);
}
// Change App status based on result (we assume user accepts install - no way to tell programatically)
if (status == DownloadTaskStatus.complete ||
status == DownloadTaskStatus.failed ||
status == DownloadTaskStatus.canceled) {
App? foundApp;
apps.forEach((appId, app) {
if (app.currentDownloadId == id) {
foundApp = apps[appId];
}
});
foundApp!.currentDownloadId = null;
if (status == DownloadTaskStatus.complete) {
foundApp!.installedVersion = foundApp!.latestVersion;
}
saveApp(foundApp!);
} }
StreamedResponse response =
await Client().send(Request('GET', Uri.parse(apps[appId]!.app.apkUrl)));
File downloadFile =
File('${(await getTemporaryDirectory()).path}/$appId.apk');
var length = response.contentLength;
var received = 0;
var sink = downloadFile.openWrite();
await response.stream.map((s) {
received += s.length;
apps[appId]!.downloadProgress =
(length != null ? received / length * 100 : 30);
notifyListeners();
return s;
}).pipe(sink);
await sink.close();
apps[appId]!.downloadProgress = null;
notifyListeners();
if (response.statusCode != 200) {
downloadFile.deleteSync();
throw response.reasonPhrase ?? 'Unknown Error';
} }
// Given a App (assumed valid), initiate an APK download (will trigger install callback when complete) var res = await InstallPlugin.installApk(
Future<void> backgroundDownloadAndInstallApp(App app) async { downloadFile.path, 'dev.imranr.obtainium');
Directory apkDir = Directory( print(res);
'${(await getExternalStorageDirectory())?.path as String}/apks/${app.id}');
if (apkDir.existsSync()) apkDir.deleteSync(recursive: true); apps[appId]!.app.installedVersion = apps[appId]!.app.latestVersion;
apkDir.createSync(recursive: true); saveApp(apps[appId]!.app);
String? downloadId = await FlutterDownloader.enqueue(
url: app.apkUrl, downloadFile.deleteSync();
savedDir: apkDir.path,
showNotification: true,
openFileFromNotification: false,
);
if (downloadId != null) {
app.currentDownloadId = downloadId;
saveApp(app);
} else {
throw "Could not start download";
}
} }
Future<Directory> getAppsDir() async { Future<Directory> getAppsDir() async {
@@ -171,7 +124,7 @@ class AppsProvider with ChangeNotifier {
for (int i = 0; i < appFiles.length; i++) { for (int i = 0; i < appFiles.length; i++) {
App app = App app =
App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync())); App.fromJson(jsonDecode(File(appFiles[i].path).readAsStringSync()));
apps.putIfAbsent(app.id, () => app); apps.putIfAbsent(app.id, () => AppInMemory(app, null));
} }
loadingApps = false; loadingApps = false;
notifyListeners(); notifyListeners();
@@ -180,25 +133,11 @@ class AppsProvider with ChangeNotifier {
Future<void> saveApp(App app) async { Future<void> saveApp(App app) async {
File('${(await getAppsDir()).path}/${app.id}.json') File('${(await getAppsDir()).path}/${app.id}.json')
.writeAsStringSync(jsonEncode(app.toJson())); .writeAsStringSync(jsonEncode(app.toJson()));
apps.update(app.id, (value) => app, ifAbsent: () => app); apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress),
ifAbsent: () => AppInMemory(app, null));
notifyListeners(); notifyListeners();
} }
Future<void> clearDownloadStates() async {
var appList = apps.values.toList();
int count = 0;
for (int i = 0; i < appList.length; i++) {
if (appList[i].currentDownloadId != null) {
apps[appList[i].id]?.currentDownloadId = null;
await saveApp(apps[appList[i].id]!);
count++;
}
}
if (count > 0) {
notifyListeners();
}
}
Future<void> removeApp(String appId) async { Future<void> removeApp(String appId) async {
File file = File('${(await getAppsDir()).path}/$appId.json'); File file = File('${(await getAppsDir()).path}/$appId.json');
if (file.existsSync()) { if (file.existsSync()) {
@@ -214,11 +153,11 @@ class AppsProvider with ChangeNotifier {
if (!apps.containsKey(app.id)) { if (!apps.containsKey(app.id)) {
throw 'App not found'; throw 'App not found';
} }
return app.latestVersion != apps[app.id]?.installedVersion; return app.latestVersion != apps[app.id]?.app.installedVersion;
} }
Future<App?> getUpdate(String appId) async { Future<App?> getUpdate(String appId) async {
App? currentApp = apps[appId]; App? currentApp = apps[appId]!.app;
App newApp = await SourceService().getApp(currentApp!.url); App newApp = await SourceService().getApp(currentApp!.url);
if (newApp.latestVersion != currentApp.latestVersion) { if (newApp.latestVersion != currentApp.latestVersion) {
newApp.installedVersion = currentApp.installedVersion; newApp.installedVersion = currentApp.installedVersion;
@@ -248,9 +187,9 @@ class AppsProvider with ChangeNotifier {
Future<void> installUpdates() async { Future<void> installUpdates() async {
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
for (int i = 0; i < appIds.length; i++) { for (int i = 0; i < appIds.length; i++) {
App? app = apps[appIds[i]]; App? app = apps[appIds[i]]!.app;
if (app!.installedVersion != app.latestVersion) { if (app!.installedVersion != app.latestVersion) {
await backgroundDownloadAndInstallApp(app); await downloadAndInstallLatestApp(app.id);
} }
} }
} }

View File

@@ -3,6 +3,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:html/parser.dart';
// Sub-classes used in App Source // Sub-classes used in App Source
@@ -28,6 +29,12 @@ abstract class AppSource {
AppNames getAppNames(String standardUrl); AppNames getAppNames(String standardUrl);
} }
escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}";
});
}
// App class // App class
class App { class App {
@@ -38,9 +45,8 @@ class App {
String? installedVersion; String? installedVersion;
late String latestVersion; late String latestVersion;
late String apkUrl; late String apkUrl;
String? currentDownloadId;
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.apkUrl, this.currentDownloadId); this.latestVersion, this.apkUrl);
@override @override
String toString() { String toString() {
@@ -56,10 +62,7 @@ class App {
? null ? null
: json['installedVersion'] as String, : json['installedVersion'] as String,
json['latestVersion'] as String, json['latestVersion'] as String,
json['apkUrl'] as String, json['apkUrl'] as String);
json['currentDownloadId'] == null
? null
: json['currentDownloadId'] as String);
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
@@ -69,7 +72,6 @@ class App {
'installedVersion': installedVersion, 'installedVersion': installedVersion,
'latestVersion': latestVersion, 'latestVersion': latestVersion,
'apkUrl': apkUrl, 'apkUrl': apkUrl,
'currentDownloadId': currentDownloadId
}; };
} }
@@ -94,20 +96,22 @@ class GitHub implements AppSource {
@override @override
Future<APKDetails> getLatestAPKUrl(String standardUrl) async { Future<APKDetails> getLatestAPKUrl(String standardUrl) async {
Response res = await get(Uri.parse( Response res = await get(Uri.parse('$standardUrl/releases/latest'));
'${convertURL(standardUrl, 'api.github.com/repos')}/releases/latest'));
if (res.statusCode == 200) { if (res.statusCode == 200) {
dynamic release = jsonDecode(res.body); var standardUri = Uri.parse(standardUrl);
for (int i = 0; i < release['assets'].length; i++) { var parsedHtml = parse(res.body);
if (release['assets'][i]['name'] var apkUrlList = parsedHtml.querySelectorAll('a').where((element) {
.toString() return RegExp(
.toLowerCase() '^${escapeRegEx(standardUri.path)}/releases/download/*/(?!/).*.apk\$',
.endsWith('.apk')) { caseSensitive: false)
return APKDetails(release['tag_name'], .hasMatch(element.attributes['href']!);
release['assets'][i]['browser_download_url']); }).toList();
} String? version = parsedHtml.querySelector('h1')?.innerHtml;
} if (apkUrlList.isEmpty || version == null) {
throw 'No APK found'; throw 'No APK found';
}
return APKDetails(
version, '${standardUri.origin}${apkUrlList[0].attributes['href']!}');
} else { } else {
throw 'Unable to fetch release info'; throw 'Unable to fetch release info';
} }
@@ -147,7 +151,6 @@ class SourceService {
names.name[0].toUpperCase() + names.name.substring(1), names.name[0].toUpperCase() + names.name.substring(1),
null, null,
apk.version, apk.version,
apk.downloadUrl, apk.downloadUrl);
null);
} }
} }

View File

@@ -71,6 +71,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.2" version: "3.0.2"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -118,13 +125,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_downloader:
dependency: "direct main"
description:
name: flutter_downloader
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
flutter_fgbg: flutter_fgbg:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -172,11 +172,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_web_plugins: html:
dependency: transitive dependency: "direct main"
description: flutter description:
source: sdk name: html
version: "0.0.0" url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -198,13 +200,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.0" version: "3.2.0"
js: install_plugin_v2:
dependency: transitive dependency: "direct main"
description: description:
name: js name: install_plugin_v2
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.4" version: "1.0.0"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -392,13 +394,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.8.0" version: "0.8.0"
toast:
dependency: "direct main"
description:
name: toast
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@@ -37,15 +37,15 @@ dependencies:
# 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.2
path_provider: ^2.0.11 path_provider: ^2.0.11
flutter_downloader: ^1.8.1
flutter_fgbg: ^0.2.0 flutter_fgbg: ^0.2.0
flutter_local_notifications: ^9.7.0 flutter_local_notifications: ^9.7.0
provider: ^6.0.3 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
toast: ^0.3.0
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.3
install_plugin_v2: ^1.0.0
html: ^0.15.0
dev_dependencies: dev_dependencies: